diff --git a/.bazelrc b/.bazelrc index 112c2301e2..76f81ade40 100644 --- a/.bazelrc +++ b/.bazelrc @@ -29,10 +29,13 @@ common:linux --test_env=PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin common:macos --test_env=PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin # Pass through some env vars Windows needs to use powershell? -common:windows --test_env=PATH common:windows --test_env=SYSTEMROOT common:windows --test_env=COMSPEC common:windows --test_env=WINDIR +# Rust's libtest harness runs test bodies on std-spawned threads. The default +# 2 MiB stack can be too small for large async test futures on Windows CI; see +# https://github.com/openai/codex/pull/19067 for the motivating failure. +common --test_env=RUST_MIN_STACK=8388608 # 8 MiB common --test_output=errors common --bes_results_url=https://app.buildbuddy.io/invocation/ diff --git a/.codespellignore b/.codespellignore index 947511b0c3..23924fe083 100644 --- a/.codespellignore +++ b/.codespellignore @@ -1,5 +1,6 @@ iTerm iTerm2 psuedo +SOM te TE diff --git a/.codespellrc b/.codespellrc index 87e3468c66..838b7e874e 100644 --- a/.codespellrc +++ b/.codespellrc @@ -1,6 +1,6 @@ [codespell] # Ref: https://github.com/codespell-project/codespell#using-a-config-file -skip = .git*,vendor,*-lock.yaml,*.lock,.codespellrc,*test.ts,*.jsonl,frame*.txt,*.snap,*.snap.new,*meriyah.umd.min.js +skip = .git*,vendor,*-lock.yaml,*.lock,.codespellrc,*test.ts,*.jsonl,frame*.txt,*.snap,*.snap.new check-hidden = true ignore-regex = ^\s*"image/\S+": ".*|\b(afterAll)\b ignore-words-list = ratatui,ser,iTerm,iterm2,iterm,te,TE,PASE,SEH diff --git a/.codex/skills/codex-issue-digest/SKILL.md b/.codex/skills/codex-issue-digest/SKILL.md new file mode 100644 index 0000000000..b531748f8c --- /dev/null +++ b/.codex/skills/codex-issue-digest/SKILL.md @@ -0,0 +1,102 @@ +--- +name: codex-issue-digest +description: Run a GitHub issue digest for openai/codex by feature-area labels, all areas, and configurable time windows. Use when asked to summarize recent Codex bug reports or enhancement requests, especially for owner-specific labels such as tui, exec, app, or similar areas. +--- + +# Codex Issue Digest + +## Objective + +Produce a concise, insight-oriented digest of `openai/codex` issues for the requested feature-area labels over the previous 24 hours by default. Honor a different duration when the user asks for one, for example "past week" or "48 hours". + +Include only issues that currently have `bug` or `enhancement` plus at least one requested owner label. If the user asks for all areas or all labels, collect `bug`/`enhancement` issues across all labels. + +## Inputs + +- Feature-area labels, for example `tui exec` +- `all areas` / `all labels` to scan all current feature labels +- Optional repo override, default `openai/codex` +- Optional time window, default previous 24 hours; examples: `48h`, `7d`, `1w`, `past week` + +## Workflow + +1. Run the collector from a current Codex repo checkout: + +```bash +python3 .codex/skills/codex-issue-digest/scripts/collect_issue_digest.py --labels tui exec --window-hours 24 +``` + +Use `--window "past week"` or `--window-hours 168` when the user asks for a non-default duration. Use `--all-labels` when the user says all areas or all labels. + +2. Use the JSON as the source of truth. It includes new issues, new issue comments, new reactions/upvotes, current labels, current reaction counts, model-ready `summary_inputs`, and detailed `digest_rows`. +3. Start the report with `## Summary`, then `## Details`. +4. In `## Summary`, write skim-first headlines: + - Lead with the most important fact or judgment. Do not start with aggregate counts unless the aggregate itself is the story. + - Make the first 1-3 bullets answer "what should owners pay attention to right now?" + - Bold only the critical insight phrase in each high-priority bullet, for example `**GPT-5.5 context is the dominant pressure point**`. + - Keep summary bullets short enough to scan in about 20 seconds. + - Put broad stats near the end of the summary, after the owner-relevant takeaways. + - Say clearly when there is nothing significant to act on. + - Call out any areas or themes receiving lots of user attention. + - Cluster and name themes yourself from `summary_inputs`; the collector intentionally does not hard-code issue categories. + - Use a cluster only when the issues genuinely share the same product problem. If several issues merely share a broad platform or label, describe them individually. + - Do not omit a repeated theme just because its individual issues fall below the details table cutoff. Several similar reports should be called out as a repeated customer concern. + - For single-issue rows, summarize the concern directly instead of calling it a cluster. + - Use inline numbered issue links from each relevant row's `ref_markdown`. +5. In `## Details`, include a compact table only when useful: + - Prefer rows from `digest_rows`; include a `Refs` column using each row's `ref_markdown`. + - Keep the table short; omit low-signal rows when the summary already covers them. + - Use compact columns such as marker, area, type, description, interactions, and refs. + - The `Description` cell should be a short owner-readable phrase. Use row `description`, title, body excerpts, and recent comments, but do not mechanically copy the raw GitHub issue title when it contains incidental details. + - A clear quiet/no-concern sentence when there is no meaningful signal. +6. Use the JSON `attention_marker` exactly. It is empty for normal rows, `🔥` for elevated rows, and `🔥🔥` for very high-attention rows. The actual cutoffs are in `attention_thresholds`. +7. Use inline numbered references where a row or bullet points to issues, for example `Compaction bugs [1](https://github.com/openai/codex/issues/123), [2](https://github.com/openai/codex/issues/456)`. Do not add a separate footnotes section. +8. Label `interactions` as `Interactions`; it counts posts/comments/reactions during the requested window, not unique people. +9. Mention the collector `script_version`, repo checkout `git_head`, and time window in the digest footer or final line. + +## Reaction Handling + +The collector uses GitHub reactions endpoints, which include `created_at`, to count reactions created during the digest window for hydrated issues. It reports both in-window reaction counts and current reaction totals. Treat current reaction totals as standing engagement, and treat `new_reactions` / `new_upvotes` as windowed activity. + +By default, the collector fetches issue comments with `since=` and caps the number of comment pages per issue. This keeps very long historical threads from dominating a digest run and focuses the report on recent posts. Use `--fetch-all-comments` only when exhaustive comment history is more important than runtime. + +GitHub issue search is still seeded by issue `updated_at`, so a purely reaction-only issue may be missed if reactions do not bump `updated_at`. Covering every reaction-only case would require either a persisted snapshot store or a broader scan of labeled issues. + +## Attention Markers + +The collector scales attention markers by the requested time window. The baseline is 10 human user interactions for `🔥` and 20 for `🔥🔥` over 24 hours; longer or shorter windows scale those cutoffs linearly and round up. For example, a one-week report uses 70 and 140 interactions. Human user interactions are human-authored new issue posts, human-authored new comments, and human reactions created during the window, including upvotes. Bot posts and bot reactions are excluded. In prose, explain this as high user interaction rather than naming the emoji. + +## Freshness + +The automation should run from a repo checkout that contains this skill. For shared daily use, prefer one of these patterns: + +- Run the automation in a checkout that is refreshed before the automation starts, for example with `git pull --ff-only`. +- If the automation cannot safely mutate the checkout, have it report the current `git_head` from the collector output so readers know which skill/script version produced the digest. + +## Sample Owner Prompt + +```text +Use $codex-issue-digest to run the Codex issue digest for labels tui and exec over the previous 24 hours. +``` + +```text +Use $codex-issue-digest to run the Codex issue digest for all areas over the past week. +``` + +## Validation + +Dry run the collector against recent issues: + +```bash +python3 .codex/skills/codex-issue-digest/scripts/collect_issue_digest.py --labels tui exec --window-hours 24 +``` + +```bash +python3 .codex/skills/codex-issue-digest/scripts/collect_issue_digest.py --all-labels --window "past week" --limit-issues 10 +``` + +Run the focused script tests: + +```bash +pytest .codex/skills/codex-issue-digest/scripts/test_collect_issue_digest.py +``` diff --git a/.codex/skills/codex-issue-digest/agents/openai.yaml b/.codex/skills/codex-issue-digest/agents/openai.yaml new file mode 100644 index 0000000000..706ce5e11b --- /dev/null +++ b/.codex/skills/codex-issue-digest/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Codex Issue Digest" + short_description: "Summarize Codex issues by labels or all areas" + default_prompt: "Use $codex-issue-digest to run the Codex issue digest for labels tui and exec over the previous 24 hours." diff --git a/.codex/skills/codex-issue-digest/scripts/collect_issue_digest.py b/.codex/skills/codex-issue-digest/scripts/collect_issue_digest.py new file mode 100755 index 0000000000..e211af08f8 --- /dev/null +++ b/.codex/skills/codex-issue-digest/scripts/collect_issue_digest.py @@ -0,0 +1,988 @@ +#!/usr/bin/env python3 +"""Collect recent openai/codex issue activity for owner-focused digests.""" + +import argparse +import json +import math +import re +import subprocess +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path +from urllib.parse import quote + +SCRIPT_VERSION = 2 +QUALIFYING_KIND_LABELS = ("bug", "enhancement") +REACTION_KEYS = ("+1", "-1", "laugh", "hooray", "confused", "heart", "rocket", "eyes") +BASE_ATTENTION_WINDOW_HOURS = 24.0 +ONE_ATTENTION_INTERACTION_THRESHOLD = 10 +TWO_ATTENTION_INTERACTION_THRESHOLD = 20 +ALL_LABEL_PHRASES = {"all", "all areas", "all labels", "all-areas", "all-labels", "*"} + + +class GhCommandError(RuntimeError): + pass + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Collect recent GitHub issue activity for a Codex owner digest." + ) + parser.add_argument( + "--repo", default="openai/codex", help="OWNER/REPO, default openai/codex" + ) + parser.add_argument( + "--labels", + nargs="+", + default=[], + help="Feature-area labels owned by the digest recipient, for example: tui exec", + ) + parser.add_argument( + "--all-labels", + action="store_true", + help="Collect bug/enhancement issues across all feature-area labels", + ) + parser.add_argument( + "--window", + help='Lookback duration such as "24h", "7d", "1w", or "past week"', + ) + parser.add_argument( + "--window-hours", type=float, default=24.0, help="Lookback window" + ) + parser.add_argument( + "--since", help="UTC ISO timestamp override for the window start" + ) + parser.add_argument("--until", help="UTC ISO timestamp override for the window end") + parser.add_argument( + "--limit-issues", + type=int, + default=200, + help="Maximum candidate issues to hydrate after search", + ) + parser.add_argument( + "--body-chars", type=int, default=1200, help="Issue body excerpt length" + ) + parser.add_argument( + "--comment-chars", type=int, default=900, help="Comment excerpt length" + ) + parser.add_argument( + "--max-comment-pages", + type=int, + default=3, + help=( + "Maximum pages of issue comments to hydrate per issue after applying the " + "window filter. Use 0 with --fetch-all-comments for no page cap." + ), + ) + parser.add_argument( + "--fetch-all-comments", + action="store_true", + help="Hydrate complete issue comment histories instead of only window-updated comments.", + ) + return parser.parse_args() + + +def parse_timestamp(value, arg_name): + if value is None: + return None + normalized = value.strip() + if not normalized: + return None + if normalized.endswith("Z"): + normalized = f"{normalized[:-1]}+00:00" + try: + parsed = datetime.fromisoformat(normalized) + except ValueError as err: + raise ValueError(f"{arg_name} must be an ISO timestamp") from err + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def format_timestamp(value): + return ( + value.astimezone(timezone.utc) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z") + ) + + +def resolve_window(args): + until = parse_timestamp(args.until, "--until") or datetime.now(timezone.utc) + since = parse_timestamp(args.since, "--since") + if since is None: + hours = parse_duration_hours(getattr(args, "window", None)) + if hours is None: + hours = getattr(args, "window_hours", 24.0) + if hours <= 0: + raise ValueError("window duration must be > 0") + since = until - timedelta(hours=hours) + if since >= until: + raise ValueError("--since must be before --until") + return since, until + + +def parse_duration_hours(value): + if value is None: + return None + text = value.strip().casefold().replace("_", " ") + if not text: + return None + text = re.sub(r"^(past|last)\s+", "", text) + aliases = { + "day": 24.0, + "24h": 24.0, + "week": 168.0, + "7d": 168.0, + } + if text in aliases: + return aliases[text] + match = re.fullmatch(r"(\d+(?:\.\d+)?)\s*(h|hr|hrs|hour|hours)", text) + if match: + return float(match.group(1)) + match = re.fullmatch(r"(\d+(?:\.\d+)?)\s*(d|day|days)", text) + if match: + return float(match.group(1)) * 24.0 + match = re.fullmatch(r"(\d+(?:\.\d+)?)\s*(w|week|weeks)", text) + if match: + return float(match.group(1)) * 168.0 + raise ValueError(f"Unsupported duration: {value}") + + +def normalize_requested_labels(labels, all_labels=False): + out = [] + seen = set() + for raw in labels: + for piece in raw.split(","): + label = piece.strip() + if not label: + continue + key = label.casefold() + if key not in seen: + out.append(label) + seen.add(key) + phrase = " ".join(label.casefold() for label in out) + if all_labels or phrase in ALL_LABEL_PHRASES: + return [], True + if not out: + raise ValueError( + "At least one feature-area label is required, or use --all-labels" + ) + return out, False + + +def quote_label(label): + if re.fullmatch(r"[A-Za-z0-9_.:-]+", label): + return f"label:{label}" + escaped = label.replace('"', '\\"') + return f'label:"{escaped}"' + + +def build_search_queries( + repo, owner_labels, since, kind_labels=QUALIFYING_KIND_LABELS, all_labels=False +): + since_date = since.date().isoformat() + queries = [] + if all_labels: + for kind_label in kind_labels: + queries.append( + " ".join( + [ + f"repo:{repo}", + "is:issue", + f"updated:>={since_date}", + quote_label(kind_label), + ] + ) + ) + return queries + for owner_label in owner_labels: + for kind_label in kind_labels: + queries.append( + " ".join( + [ + f"repo:{repo}", + "is:issue", + f"updated:>={since_date}", + quote_label(owner_label), + quote_label(kind_label), + ] + ) + ) + return queries + + +def _format_gh_error(cmd, err): + stdout = (err.stdout or "").strip() + stderr = (err.stderr or "").strip() + parts = [f"GitHub CLI command failed: {' '.join(cmd)}"] + if stdout: + parts.append(f"stdout: {stdout}") + if stderr: + parts.append(f"stderr: {stderr}") + return "\n".join(parts) + + +def gh_json(args): + cmd = ["gh", *args] + try: + proc = subprocess.run(cmd, check=True, capture_output=True, text=True) + except FileNotFoundError as err: + raise GhCommandError("`gh` command not found") from err + except subprocess.CalledProcessError as err: + raise GhCommandError(_format_gh_error(cmd, err)) from err + raw = proc.stdout.strip() + if not raw: + return None + try: + return json.loads(raw) + except json.JSONDecodeError as err: + raise GhCommandError( + f"Failed to parse JSON from gh output for {' '.join(args)}" + ) from err + + +def gh_text(args): + cmd = ["gh", *args] + try: + proc = subprocess.run(cmd, check=True, capture_output=True, text=True) + except (FileNotFoundError, subprocess.CalledProcessError): + return "" + return proc.stdout.strip() + + +def git_head(): + try: + proc = subprocess.run( + ["git", "rev-parse", "--short=12", "HEAD"], + check=True, + capture_output=True, + text=True, + ) + except (FileNotFoundError, subprocess.CalledProcessError): + return None + return proc.stdout.strip() or None + + +def skill_relative_path(): + try: + return str(Path(__file__).resolve().relative_to(Path.cwd().resolve())) + except ValueError: + return str(Path(__file__).resolve()) + + +def gh_api_list_paginated(endpoint, per_page=100, max_pages=None, with_metadata=False): + items = [] + page = 1 + truncated = False + while True: + sep = "&" if "?" in endpoint else "?" + page_endpoint = f"{endpoint}{sep}per_page={per_page}&page={page}" + payload = gh_json(["api", page_endpoint]) + if payload is None: + break + if not isinstance(payload, list): + raise GhCommandError(f"Unexpected paginated payload from gh api {endpoint}") + items.extend(payload) + if len(payload) < per_page: + break + if max_pages is not None and page >= max_pages: + truncated = True + break + page += 1 + if with_metadata: + return { + "items": items, + "truncated": truncated, + "pages": page, + "max_pages": max_pages, + } + return items + + +def search_issue_numbers(queries, limit): + numbers = {} + for query in queries: + page = 1 + while True: + payload = gh_json( + [ + "api", + "search/issues", + "-X", + "GET", + "-f", + f"q={query}", + "-f", + "per_page=100", + "-f", + f"page={page}", + ] + ) + if not isinstance(payload, dict): + raise GhCommandError("Unexpected payload from GitHub issue search") + items = payload.get("items") or [] + if not isinstance(items, list): + raise GhCommandError("Expected search `items` to be a list") + for item in items: + if not isinstance(item, dict): + continue + number = item.get("number") + if isinstance(number, int): + numbers[number] = str(item.get("updated_at") or "") + if len(items) < 100 or len(numbers) >= limit: + break + page += 1 + ordered = sorted( + numbers, key=lambda number: (numbers[number], number), reverse=True + ) + return ordered[:limit] + + +def fetch_issue(repo, number): + payload = gh_json(["api", f"repos/{repo}/issues/{number}"]) + if not isinstance(payload, dict): + raise GhCommandError(f"Unexpected issue payload for #{number}") + return payload + + +def fetch_comments(repo, number, since=None, max_pages=None): + endpoint = f"repos/{repo}/issues/{number}/comments" + if since is not None: + endpoint = f"{endpoint}?since={quote(format_timestamp(since), safe='')}" + return gh_api_list_paginated( + endpoint, + max_pages=max_pages, + with_metadata=True, + ) + + +def fetch_reactions_for_item(endpoint, item): + if reaction_summary(item)["total"] <= 0: + return [] + return gh_api_list_paginated(endpoint) + + +def fetch_comment_reactions(repo, comments): + reactions_by_comment_id = {} + for comment in comments: + comment_id = comment.get("id") + if comment_id in (None, ""): + continue + endpoint = f"repos/{repo}/issues/comments/{comment_id}/reactions" + reactions_by_comment_id[comment_id] = fetch_reactions_for_item( + endpoint, comment + ) + return reactions_by_comment_id + + +def extract_login(user_obj): + if isinstance(user_obj, dict): + return str(user_obj.get("login") or "") + return "" + + +def is_bot_login(login): + return bool(login) and login.lower().endswith("[bot]") + + +def is_human_user(user_obj): + login = extract_login(user_obj) + return bool(login) and not is_bot_login(login) + + +def label_names(issue): + labels = [] + for label in issue.get("labels") or []: + if isinstance(label, dict) and label.get("name"): + labels.append(str(label["name"])) + return sorted(labels, key=str.casefold) + + +def matching_labels(labels, requested): + labels_by_key = {label.casefold(): label for label in labels} + return [label for label in requested if label.casefold() in labels_by_key] + + +def area_labels(labels): + kind_keys = {label.casefold() for label in QUALIFYING_KIND_LABELS} + return [label for label in labels if label.casefold() not in kind_keys] + + +def attention_thresholds_for_window(window_hours): + if window_hours <= 0: + raise ValueError("window_hours must be > 0") + window_hours = round(window_hours, 6) + scale = window_hours / BASE_ATTENTION_WINDOW_HOURS + elevated = max(1, math.ceil(ONE_ATTENTION_INTERACTION_THRESHOLD * scale)) + very_high = max( + elevated + 1, math.ceil(TWO_ATTENTION_INTERACTION_THRESHOLD * scale) + ) + return { + "base_window_hours": BASE_ATTENTION_WINDOW_HOURS, + "window_hours": round(window_hours, 3), + "scale": round(scale, 3), + "elevated": elevated, + "very_high": very_high, + } + + +def attention_level_for(user_interactions, attention_thresholds=None): + thresholds = attention_thresholds or attention_thresholds_for_window( + BASE_ATTENTION_WINDOW_HOURS + ) + if user_interactions >= thresholds["very_high"]: + return 2 + if user_interactions >= thresholds["elevated"]: + return 1 + return 0 + + +def attention_marker_for(user_interactions, attention_thresholds=None): + return "🔥" * attention_level_for(user_interactions, attention_thresholds) + + +def reaction_summary(item): + reactions = item.get("reactions") + if not isinstance(reactions, dict): + return {"total": 0, "counts": {}} + counts = {} + for key in REACTION_KEYS: + value = reactions.get(key, 0) + if isinstance(value, int) and value: + counts[key] = value + total = reactions.get("total_count") + if not isinstance(total, int): + total = sum(counts.values()) + return {"total": total, "counts": counts} + + +def reaction_event_summary(reactions, since, until): + counts = {} + total = 0 + for reaction in reactions or []: + if not isinstance(reaction, dict): + continue + if not is_in_window(str(reaction.get("created_at") or ""), since, until): + continue + if not is_human_user(reaction.get("user")): + continue + content = str(reaction.get("content") or "") + if not content: + continue + counts[content] = counts.get(content, 0) + 1 + total += 1 + return { + "total": total, + "counts": counts, + "upvotes": counts.get("+1", 0), + } + + +def compact_text(value, limit): + text = re.sub(r"\s+", " ", str(value or "")).strip() + if limit <= 0: + return "" + if len(text) <= limit: + return text + return f"{text[: max(limit - 1, 0)].rstrip()}..." + + +def clean_title_for_description(title): + cleaned = re.sub(r"\s+", " ", str(title or "")).strip() + cleaned = re.sub( + r"^(codex(?: desktop| app|\.app| cli)?|desktop|windows codex app)\s*[:,-]\s*", + "", + cleaned, + flags=re.IGNORECASE, + ) + cleaned = re.sub(r"^on windows,\s*", "Windows: ", cleaned, flags=re.IGNORECASE) + cleaned = cleaned.strip(" -:;") + return compact_text(cleaned, 80) or "Issue needs owner review" + + +def issue_description(issue): + return clean_title_for_description(issue.get("title")) + + +def is_in_window(timestamp, since, until): + parsed = parse_timestamp(timestamp, "timestamp") + if parsed is None: + return False + return since <= parsed < until + + +def summarize_comment( + comment, comment_chars, reaction_events=None, since=None, until=None +): + reactions = reaction_summary(comment) + new_reactions = ( + reaction_event_summary(reaction_events, since, until) + if since is not None and until is not None + else {"total": 0, "counts": {}, "upvotes": 0} + ) + human_user_interaction = is_human_user(comment.get("user")) + return { + "id": comment.get("id"), + "author": extract_login(comment.get("user")), + "author_association": str(comment.get("author_association") or ""), + "created_at": str(comment.get("created_at") or ""), + "updated_at": str(comment.get("updated_at") or ""), + "url": str(comment.get("html_url") or ""), + "human_user_interaction": human_user_interaction, + "reactions": reactions["counts"], + "reaction_total": reactions["total"], + "new_reactions": new_reactions["total"], + "new_upvotes": new_reactions["upvotes"], + "new_reaction_counts": new_reactions["counts"], + "body_excerpt": compact_text(comment.get("body"), comment_chars), + } + + +def summarize_issue( + issue, + comments, + requested_labels, + since, + until, + body_chars, + comment_chars, + issue_reaction_events=None, + comment_reactions_by_id=None, + all_labels=False, + comments_hydration=None, + attention_thresholds=None, +): + labels = label_names(issue) + labels_by_key = {label.casefold() for label in labels} + kind_labels = [ + label for label in QUALIFYING_KIND_LABELS if label.casefold() in labels_by_key + ] + if all_labels: + owner_labels = area_labels(labels) or ["unlabeled"] + else: + owner_labels = matching_labels(labels, requested_labels) + if not kind_labels or not owner_labels: + return None + + updated_at = str(issue.get("updated_at") or "") + if not is_in_window(updated_at, since, until): + return None + + new_issue = is_in_window(str(issue.get("created_at") or ""), since, until) + comment_reactions_by_id = comment_reactions_by_id or {} + new_comments = [ + summarize_comment( + comment, + comment_chars, + reaction_events=comment_reactions_by_id.get(comment.get("id")), + since=since, + until=until, + ) + for comment in comments + if is_in_window(str(comment.get("created_at") or ""), since, until) + ] + new_comments.sort(key=lambda item: (item["created_at"], str(item["id"]))) + + issue_reactions = reaction_summary(issue) + issue_reaction_events_summary = reaction_event_summary( + issue_reaction_events, since, until + ) + comment_reaction_events_summary = reaction_event_summary( + [ + reaction + for reactions in comment_reactions_by_id.values() + for reaction in reactions + ], + since, + until, + ) + new_reactions = ( + issue_reaction_events_summary["total"] + + comment_reaction_events_summary["total"] + ) + new_upvotes = ( + issue_reaction_events_summary["upvotes"] + + comment_reaction_events_summary["upvotes"] + ) + all_comment_reaction_total = sum( + reaction_summary(comment)["total"] for comment in comments + ) + new_comment_reaction_total = sum( + comment["reaction_total"] for comment in new_comments + ) + new_issue_user_interaction = new_issue and is_human_user(issue.get("user")) + new_comment_user_interactions = sum( + 1 for comment in new_comments if comment["human_user_interaction"] + ) + user_interactions = ( + int(new_issue_user_interaction) + new_comment_user_interactions + new_reactions + ) + attention_level = attention_level_for(user_interactions, attention_thresholds) + attention_marker = attention_marker_for(user_interactions, attention_thresholds) + updated_without_visible_new_post = ( + not new_issue and not new_comments and new_reactions == 0 + ) + + engagement_score = ( + len(new_comments) * 3 + + new_reactions + + issue_reactions["total"] + + new_comment_reaction_total + + min(int(issue.get("comments") or len(comments) or 0), 10) + ) + + return { + "number": issue.get("number"), + "title": str(issue.get("title") or ""), + "description": issue_description(issue), + "url": str(issue.get("html_url") or ""), + "state": str(issue.get("state") or ""), + "author": extract_login(issue.get("user")), + "author_association": str(issue.get("author_association") or ""), + "created_at": str(issue.get("created_at") or ""), + "updated_at": updated_at, + "labels": labels, + "kind_labels": kind_labels, + "owner_labels": owner_labels, + "comments_total": int(issue.get("comments") or len(comments) or 0), + "comments_hydration": comments_hydration + or { + "fetched": len(comments), + "since": None, + "truncated": False, + "max_pages": None, + }, + "issue_reactions": issue_reactions["counts"], + "issue_reaction_total": issue_reactions["total"], + "comment_reaction_total": all_comment_reaction_total, + "new_comment_reaction_total": new_comment_reaction_total, + "new_issue_reactions": issue_reaction_events_summary["total"], + "new_issue_upvotes": issue_reaction_events_summary["upvotes"], + "new_comment_reactions": comment_reaction_events_summary["total"], + "new_comment_upvotes": comment_reaction_events_summary["upvotes"], + "new_reactions": new_reactions, + "new_upvotes": new_upvotes, + "user_interactions": user_interactions, + "attention": attention_level > 0, + "attention_level": attention_level, + "attention_marker": attention_marker, + "engagement_score": engagement_score, + "activity": { + "new_issue": new_issue, + "new_comments": len(new_comments), + "new_human_comments": new_comment_user_interactions, + "new_reactions": new_reactions, + "new_upvotes": new_upvotes, + "updated_without_visible_new_post": updated_without_visible_new_post, + }, + "body_excerpt": compact_text(issue.get("body"), body_chars), + "new_comments": new_comments, + } + + +def count_by_label(issues, labels): + out = {} + for label in labels: + matching = [issue for issue in issues if label in issue["owner_labels"]] + out[label] = { + "issues": len(matching), + "new_issues": sum( + 1 for issue in matching if issue["activity"]["new_issue"] + ), + "new_comments": sum( + issue["activity"]["new_comments"] for issue in matching + ), + } + return out + + +def count_by_kind(issues): + out = {} + for kind in QUALIFYING_KIND_LABELS: + matching = [issue for issue in issues if kind in issue["kind_labels"]] + out[kind] = { + "issues": len(matching), + "new_issues": sum( + 1 for issue in matching if issue["activity"]["new_issue"] + ), + "new_comments": sum( + issue["activity"]["new_comments"] for issue in matching + ), + } + return out + + +def hot_items(issues, limit=8): + ranked = sorted( + issues, + key=lambda issue: ( + issue["attention"], + issue["attention_level"], + issue["user_interactions"], + issue["engagement_score"], + issue["activity"]["new_comments"], + issue["issue_reaction_total"] + issue["comment_reaction_total"], + issue["updated_at"], + ), + reverse=True, + ) + return [ + { + "number": issue["number"], + "title": issue["title"], + "url": issue["url"], + "owner_labels": issue["owner_labels"], + "kind_labels": issue["kind_labels"], + "attention": issue["attention"], + "attention_level": issue["attention_level"], + "attention_marker": issue["attention_marker"], + "user_interactions": issue["user_interactions"], + "new_reactions": issue["new_reactions"], + "new_upvotes": issue["new_upvotes"], + "engagement_score": issue["engagement_score"], + "new_comments": issue["activity"]["new_comments"], + "reaction_total": issue["issue_reaction_total"] + + issue["comment_reaction_total"], + } + for issue in ranked[:limit] + if issue["engagement_score"] > 0 + ] + + +def ranked_digest_issues(issues): + return sorted( + issues, + key=lambda issue: ( + issue["attention"], + issue["attention_level"], + issue["user_interactions"], + issue["engagement_score"], + issue["activity"]["new_comments"], + issue["updated_at"], + ), + reverse=True, + ) + + +def digest_rows(issues, limit=10, ref_map=None): + ranked = ranked_digest_issues(issues) + if ref_map is None: + ref_map = {issue["number"]: ref for ref, issue in enumerate(ranked, start=1)} + rows = [] + for issue in ranked[:limit]: + ref = ref_map[issue["number"]] + reaction_total = issue["issue_reaction_total"] + issue["comment_reaction_total"] + rows.append( + { + "ref": ref, + "ref_markdown": f"[{ref}]({issue['url']})", + "marker": issue["attention_marker"], + "attention_marker": issue["attention_marker"], + "number": issue["number"], + "description": issue["description"], + "title": issue["title"], + "url": issue["url"], + "area": ", ".join(issue["owner_labels"]), + "kind": ", ".join(issue["kind_labels"]), + "state": issue["state"], + "interactions": issue["user_interactions"], + "user_interactions": issue["user_interactions"], + "new_reactions": issue["new_reactions"], + "new_upvotes": issue["new_upvotes"], + "current_reactions": reaction_total, + } + ) + return rows + + +def issue_ref_markdown(issue, ref_map): + ref = ref_map[issue["number"]] + return f"[{ref}]({issue['url']})" + + +def summary_inputs(issues, limit=80, ref_map=None): + ranked = ranked_digest_issues(issues) + if ref_map is None: + ref_map = {issue["number"]: ref for ref, issue in enumerate(ranked, start=1)} + rows = [] + for issue in ranked[:limit]: + rows.append( + { + "ref": ref_map[issue["number"]], + "ref_markdown": issue_ref_markdown(issue, ref_map), + "number": issue["number"], + "title": issue["title"], + "description": issue["description"], + "url": issue["url"], + "labels": issue["labels"], + "owner_labels": issue["owner_labels"], + "kind_labels": issue["kind_labels"], + "state": issue.get("state", ""), + "attention_marker": issue.get("attention_marker", ""), + "interactions": issue["user_interactions"], + "new_comments": issue["activity"].get("new_comments", 0), + "new_reactions": issue.get("new_reactions", 0), + "new_upvotes": issue.get("new_upvotes", 0), + "current_reactions": issue.get("issue_reaction_total", 0) + + issue.get("comment_reaction_total", 0), + } + ) + return rows + + +def collect_digest(args): + since, until = resolve_window(args) + window_hours = (until - since).total_seconds() / 3600 + attention_thresholds = attention_thresholds_for_window(window_hours) + requested_labels, all_labels = normalize_requested_labels( + args.labels, all_labels=args.all_labels + ) + queries = build_search_queries( + args.repo, requested_labels, since, all_labels=all_labels + ) + numbers = search_issue_numbers(queries, args.limit_issues) + gh_version_output = gh_text(["--version"]) + + issues = [] + max_comment_pages = None if args.max_comment_pages <= 0 else args.max_comment_pages + for number in numbers: + issue = fetch_issue(args.repo, number) + comments_since = None if args.fetch_all_comments else since + comments_payload = fetch_comments( + args.repo, + number, + since=comments_since, + max_pages=max_comment_pages, + ) + comments = comments_payload["items"] + issue_reaction_events = fetch_reactions_for_item( + f"repos/{args.repo}/issues/{number}/reactions", issue + ) + comment_reactions_by_id = fetch_comment_reactions(args.repo, comments) + comments_hydration = { + "fetched": len(comments), + "total": int(issue.get("comments") or len(comments) or 0), + "since": format_timestamp(comments_since) if comments_since else None, + "truncated": comments_payload["truncated"], + "max_pages": comments_payload["max_pages"], + "fetch_all_comments": args.fetch_all_comments, + } + summary = summarize_issue( + issue, + comments, + requested_labels, + since, + until, + args.body_chars, + args.comment_chars, + issue_reaction_events=issue_reaction_events, + comment_reactions_by_id=comment_reactions_by_id, + all_labels=all_labels, + comments_hydration=comments_hydration, + attention_thresholds=attention_thresholds, + ) + if summary is not None: + issues.append(summary) + + issues.sort( + key=lambda issue: (issue["updated_at"], int(issue["number"] or 0)), reverse=True + ) + totals = { + "candidate_issues": len(numbers), + "included_issues": len(issues), + "new_issues": sum(1 for issue in issues if issue["activity"]["new_issue"]), + "issues_with_new_comments": sum( + 1 for issue in issues if issue["activity"]["new_comments"] > 0 + ), + "new_comments": sum(issue["activity"]["new_comments"] for issue in issues), + "comments_fetched": sum( + issue["comments_hydration"]["fetched"] for issue in issues + ), + "issues_with_truncated_comment_hydration": sum( + 1 for issue in issues if issue["comments_hydration"]["truncated"] + ), + "updated_without_visible_new_post": sum( + 1 + for issue in issues + if issue["activity"]["updated_without_visible_new_post"] + ), + "issue_reactions_current_total": sum( + issue["issue_reaction_total"] for issue in issues + ), + "comment_reactions_current_total": sum( + issue["comment_reaction_total"] for issue in issues + ), + "new_reactions": sum(issue["new_reactions"] for issue in issues), + "new_upvotes": sum(issue["new_upvotes"] for issue in issues), + "user_interactions": sum(issue["user_interactions"] for issue in issues), + } + ranked = ranked_digest_issues(issues) + ref_map = {issue["number"]: ref for ref, issue in enumerate(ranked, start=1)} + filter_label = "all" if all_labels else requested_labels + + return { + "generated_at": format_timestamp(datetime.now(timezone.utc)), + "source": { + "repo": args.repo, + "skill": "codex-issue-digest", + "collector": skill_relative_path(), + "script_version": SCRIPT_VERSION, + "git_head": git_head(), + "gh_version": gh_version_output.splitlines()[0] + if gh_version_output + else None, + }, + "window": { + "since": format_timestamp(since), + "until": format_timestamp(until), + "hours": round(window_hours, 3), + }, + "attention_thresholds": attention_thresholds, + "filters": { + "owner_labels": filter_label, + "all_labels": all_labels, + "kind_labels": list(QUALIFYING_KIND_LABELS), + }, + "collection_notes": [ + "Issues are selected when they currently have bug or enhancement plus at least one requested owner label and were updated during the window.", + "By default, issue comments are fetched with since=window_start and a max page cap to avoid long historical threads; use --fetch-all-comments when exhaustive comment history is needed.", + "New issue comments are filtered by comment creation time within the window from the fetched comment set.", + "Reaction events are counted by GitHub reaction created_at timestamps for hydrated issues and fetched comments.", + "Current reaction totals are standing engagement signals; new_reactions and new_upvotes are windowed activity.", + "The collector does not assign semantic clusters; use summary_inputs as model-ready evidence for report-time clustering.", + "Pure reaction-only issues may be missed if GitHub issue search does not surface them via updated_at.", + "Issues updated during the window without a new issue body or new comment are retained because label/status edits can still be useful owner signals.", + ], + "totals": totals, + "by_owner_label": count_by_label( + issues, + sorted( + {area for issue in issues for area in issue["owner_labels"]}, + key=str.casefold, + ) + if all_labels + else requested_labels, + ), + "by_kind_label": count_by_kind(issues), + "hot_items": hot_items(issues), + "summary_inputs": summary_inputs(issues, ref_map=ref_map), + "digest_rows": digest_rows(issues, ref_map=ref_map), + "issues": issues, + } + + +def main(): + args = parse_args() + try: + digest = collect_digest(args) + except (GhCommandError, RuntimeError, ValueError) as err: + sys.stderr.write(f"collect_issue_digest.py error: {err}\n") + return 1 + sys.stdout.write(json.dumps(digest, indent=2, sort_keys=True) + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.codex/skills/codex-issue-digest/scripts/test_collect_issue_digest.py b/.codex/skills/codex-issue-digest/scripts/test_collect_issue_digest.py new file mode 100644 index 0000000000..1c283ea2f6 --- /dev/null +++ b/.codex/skills/codex-issue-digest/scripts/test_collect_issue_digest.py @@ -0,0 +1,614 @@ +import importlib.util +from datetime import timezone +from pathlib import Path + + +MODULE_PATH = Path(__file__).with_name("collect_issue_digest.py") +MODULE_SPEC = importlib.util.spec_from_file_location( + "collect_issue_digest", MODULE_PATH +) +collect_issue_digest = importlib.util.module_from_spec(MODULE_SPEC) +assert MODULE_SPEC.loader is not None +MODULE_SPEC.loader.exec_module(collect_issue_digest) + + +def test_build_search_queries_uses_each_owner_and_kind_label(): + since = collect_issue_digest.parse_timestamp("2026-04-25T12:34:56Z", "--since") + + queries = collect_issue_digest.build_search_queries( + "openai/codex", ["tui", "exec"], since + ) + + assert queries == [ + "repo:openai/codex is:issue updated:>=2026-04-25 label:tui label:bug", + "repo:openai/codex is:issue updated:>=2026-04-25 label:tui label:enhancement", + "repo:openai/codex is:issue updated:>=2026-04-25 label:exec label:bug", + "repo:openai/codex is:issue updated:>=2026-04-25 label:exec label:enhancement", + ] + + +def test_build_search_queries_can_scan_all_labels(): + since = collect_issue_digest.parse_timestamp("2026-04-25T12:34:56Z", "--since") + + queries = collect_issue_digest.build_search_queries( + "openai/codex", [], since, all_labels=True + ) + + assert queries == [ + "repo:openai/codex is:issue updated:>=2026-04-25 label:bug", + "repo:openai/codex is:issue updated:>=2026-04-25 label:enhancement", + ] + + +def test_normalize_requested_labels_accepts_all_area_phrases(): + assert collect_issue_digest.normalize_requested_labels(["all", "areas"]) == ( + [], + True, + ) + assert collect_issue_digest.normalize_requested_labels(["all-labels"]) == ( + [], + True, + ) + + +def test_summarize_issue_keeps_new_comments_and_reaction_signals(): + since = collect_issue_digest.parse_timestamp("2026-04-25T00:00:00Z", "--since") + until = collect_issue_digest.parse_timestamp("2026-04-26T00:00:00Z", "--until") + issue = { + "number": 123, + "title": "TUI does not redraw", + "html_url": "https://github.com/openai/codex/issues/123", + "state": "open", + "created_at": "2026-04-24T20:00:00Z", + "updated_at": "2026-04-25T10:00:00Z", + "user": {"login": "alice"}, + "author_association": "NONE", + "comments": 2, + "body": "The terminal freezes after resize.", + "labels": [{"name": "bug"}, {"name": "tui"}], + "reactions": {"total_count": 3, "+1": 2, "rocket": 1}, + } + comments = [ + { + "id": 1, + "created_at": "2026-04-25T11:00:00Z", + "updated_at": "2026-04-25T11:00:00Z", + "html_url": "https://github.com/openai/codex/issues/123#issuecomment-1", + "user": {"login": "bob"}, + "author_association": "MEMBER", + "body": "I can reproduce this on main.", + "reactions": {"total_count": 4, "heart": 1, "+1": 3}, + }, + { + "id": 2, + "created_at": "2026-04-24T11:00:00Z", + "updated_at": "2026-04-24T11:00:00Z", + "html_url": "https://github.com/openai/codex/issues/123#issuecomment-2", + "user": {"login": "carol"}, + "author_association": "NONE", + "body": "Older comment.", + "reactions": {"total_count": 1, "eyes": 1}, + }, + ] + + summary = collect_issue_digest.summarize_issue( + issue, + comments, + ["tui", "exec"], + since, + until, + body_chars=200, + comment_chars=200, + ) + + assert summary == { + "number": 123, + "title": "TUI does not redraw", + "description": "TUI does not redraw", + "url": "https://github.com/openai/codex/issues/123", + "state": "open", + "author": "alice", + "author_association": "NONE", + "created_at": "2026-04-24T20:00:00Z", + "updated_at": "2026-04-25T10:00:00Z", + "labels": ["bug", "tui"], + "kind_labels": ["bug"], + "owner_labels": ["tui"], + "comments_total": 2, + "comments_hydration": { + "fetched": 2, + "since": None, + "truncated": False, + "max_pages": None, + }, + "issue_reactions": {"+1": 2, "rocket": 1}, + "issue_reaction_total": 3, + "comment_reaction_total": 5, + "new_comment_reaction_total": 4, + "new_issue_reactions": 0, + "new_issue_upvotes": 0, + "new_comment_reactions": 0, + "new_comment_upvotes": 0, + "new_reactions": 0, + "new_upvotes": 0, + "user_interactions": 1, + "attention": False, + "attention_level": 0, + "attention_marker": "", + "engagement_score": 12, + "activity": { + "new_issue": False, + "new_comments": 1, + "new_human_comments": 1, + "new_reactions": 0, + "new_upvotes": 0, + "updated_without_visible_new_post": False, + }, + "body_excerpt": "The terminal freezes after resize.", + "new_comments": [ + { + "id": 1, + "author": "bob", + "author_association": "MEMBER", + "created_at": "2026-04-25T11:00:00Z", + "updated_at": "2026-04-25T11:00:00Z", + "url": "https://github.com/openai/codex/issues/123#issuecomment-1", + "human_user_interaction": True, + "reactions": {"+1": 3, "heart": 1}, + "reaction_total": 4, + "new_reactions": 0, + "new_upvotes": 0, + "new_reaction_counts": {}, + "body_excerpt": "I can reproduce this on main.", + } + ], + } + + +def test_summarize_issue_filters_non_owner_or_non_kind_labels(): + since = collect_issue_digest.parse_timestamp("2026-04-25T00:00:00Z", "--since") + until = collect_issue_digest.parse_timestamp("2026-04-26T00:00:00Z", "--until") + base_issue = { + "number": 1, + "title": "Question", + "created_at": "2026-04-25T01:00:00Z", + "updated_at": "2026-04-25T01:00:00Z", + "labels": [{"name": "question"}, {"name": "tui"}], + } + + assert ( + collect_issue_digest.summarize_issue( + base_issue, + [], + ["tui"], + since, + until, + body_chars=100, + comment_chars=100, + ) + is None + ) + + issue_without_owner = dict(base_issue) + issue_without_owner["labels"] = [{"name": "bug"}, {"name": "app"}] + + assert ( + collect_issue_digest.summarize_issue( + issue_without_owner, + [], + ["tui"], + since, + until, + body_chars=100, + comment_chars=100, + ) + is None + ) + + +def test_resolve_window_defaults_to_previous_hours(): + class Args: + since = None + until = "2026-04-26T12:00:00Z" + window_hours = 24 + + since, until = collect_issue_digest.resolve_window(Args()) + + assert since.isoformat() == "2026-04-25T12:00:00+00:00" + assert until.tzinfo == timezone.utc + + +def test_parse_duration_hours_accepts_common_phrases(): + assert collect_issue_digest.parse_duration_hours("past week") == 168 + assert collect_issue_digest.parse_duration_hours("48h") == 48 + assert collect_issue_digest.parse_duration_hours("2 days") == 48 + assert collect_issue_digest.parse_duration_hours("1w") == 168 + + +def test_attention_thresholds_scale_by_window_length(): + one_day = collect_issue_digest.attention_thresholds_for_window(24) + assert one_day["elevated"] == 10 + assert one_day["very_high"] == 20 + + half_day = collect_issue_digest.attention_thresholds_for_window(12) + assert half_day["elevated"] == 5 + assert half_day["very_high"] == 10 + + week = collect_issue_digest.attention_thresholds_for_window(168) + assert week["elevated"] == 70 + assert week["very_high"] == 140 + assert collect_issue_digest.attention_marker_for(69, week) == "" + assert collect_issue_digest.attention_marker_for(107, week) == "🔥" + assert collect_issue_digest.attention_marker_for(140, week) == "🔥🔥" + + +def test_fetch_comments_uses_since_filter_and_page_cap(monkeypatch): + calls = [] + + def fake_gh_json(args): + calls.append(args) + return [{"id": idx} for idx in range(100)] + + monkeypatch.setattr(collect_issue_digest, "gh_json", fake_gh_json) + since = collect_issue_digest.parse_timestamp("2026-04-25T00:00:00Z", "--since") + + payload = collect_issue_digest.fetch_comments( + "openai/codex", 123, since=since, max_pages=1 + ) + + assert len(payload["items"]) == 100 + assert payload["truncated"] is True + assert payload["max_pages"] == 1 + assert calls == [ + [ + "api", + "repos/openai/codex/issues/123/comments?since=2026-04-25T00%3A00%3A00Z&per_page=100&page=1", + ] + ] + + +def test_issue_description_prefers_title_over_body_noise(): + issue = { + "title": "Codex.app GUI: MCP child processes not reaped after task completion", + "body": "A later crash mention should not override the title-level symptom.", + "labels": [{"name": "app"}, {"name": "bug"}], + } + + description = collect_issue_digest.issue_description(issue) + assert "MCP child processes" in description + assert "crash" not in description.casefold() + + +def test_attention_markers_count_human_user_interactions(): + since = collect_issue_digest.parse_timestamp("2026-04-25T00:00:00Z", "--since") + until = collect_issue_digest.parse_timestamp("2026-04-26T00:00:00Z", "--until") + issue = { + "number": 456, + "title": "Agent context is exploding", + "html_url": "https://github.com/openai/codex/issues/456", + "state": "open", + "created_at": "2026-04-25T01:00:00Z", + "updated_at": "2026-04-25T12:00:00Z", + "user": {"login": "alice"}, + "labels": [{"name": "bug"}, {"name": "agent"}], + } + comments = [ + { + "id": idx, + "created_at": "2026-04-25T02:00:00Z", + "updated_at": "2026-04-25T02:00:00Z", + "user": {"login": f"user-{idx}"}, + "body": "same here", + } + for idx in range(9) + ] + comments.append( + { + "id": 99, + "created_at": "2026-04-25T02:00:00Z", + "updated_at": "2026-04-25T02:00:00Z", + "user": {"login": "github-actions[bot]"}, + "body": "duplicate bot note", + } + ) + + summary = collect_issue_digest.summarize_issue( + issue, + comments, + ["agent"], + since, + until, + body_chars=100, + comment_chars=100, + ) + + assert summary["user_interactions"] == 10 + assert summary["activity"]["new_human_comments"] == 9 + assert summary["attention"] is True + assert summary["attention_level"] == 1 + assert summary["attention_marker"] == "🔥" + + issue["created_at"] = "2026-04-24T01:00:00Z" + comments.extend( + { + "id": idx, + "created_at": "2026-04-25T03:00:00Z", + "updated_at": "2026-04-25T03:00:00Z", + "user": {"login": f"extra-user-{idx}"}, + "body": "also seeing this", + } + for idx in range(11) + ) + + summary = collect_issue_digest.summarize_issue( + issue, + comments, + ["agent"], + since, + until, + body_chars=100, + comment_chars=100, + ) + + assert summary["user_interactions"] == 20 + assert summary["attention_level"] == 2 + assert summary["attention_marker"] == "🔥🔥" + + +def test_reactions_count_toward_attention_markers(): + since = collect_issue_digest.parse_timestamp("2026-04-25T00:00:00Z", "--since") + until = collect_issue_digest.parse_timestamp("2026-04-26T00:00:00Z", "--until") + issue = { + "number": 789, + "title": "Support 1M token context", + "html_url": "https://github.com/openai/codex/issues/789", + "state": "open", + "created_at": "2026-04-24T01:00:00Z", + "updated_at": "2026-04-25T12:00:00Z", + "user": {"login": "alice"}, + "labels": [{"name": "enhancement"}, {"name": "context"}], + "reactions": {"total_count": 20, "+1": 20}, + } + comments = [ + { + "id": 1, + "created_at": "2026-04-25T02:00:00Z", + "updated_at": "2026-04-25T02:00:00Z", + "user": {"login": "commenter"}, + "body": "please", + "reactions": {"total_count": 2, "+1": 2}, + } + ] + issue_reactions = [ + { + "content": "+1", + "created_at": "2026-04-25T03:00:00Z", + "user": {"login": f"reactor-{idx}"}, + } + for idx in range(18) + ] + comment_reactions_by_id = { + 1: [ + { + "content": "heart", + "created_at": "2026-04-25T04:00:00Z", + "user": {"login": "human-reactor"}, + }, + { + "content": "+1", + "created_at": "2026-04-25T04:00:00Z", + "user": {"login": "github-actions[bot]"}, + }, + ] + } + + summary = collect_issue_digest.summarize_issue( + issue, + comments, + ["context"], + since, + until, + body_chars=100, + comment_chars=100, + issue_reaction_events=issue_reactions, + comment_reactions_by_id=comment_reactions_by_id, + ) + + assert summary["new_reactions"] == 19 + assert summary["new_upvotes"] == 18 + assert summary["user_interactions"] == 20 + assert summary["attention_level"] == 2 + assert summary["attention_marker"] == "🔥🔥" + assert summary["new_comments"][0]["new_reactions"] == 1 + assert summary["new_comments"][0]["new_upvotes"] == 0 + + +def test_digest_rows_are_table_ready_with_concise_descriptions(): + rows = collect_issue_digest.digest_rows( + [ + { + "number": 1, + "title": "Quiet bug", + "description": "Quiet bug", + "url": "https://github.com/openai/codex/issues/1", + "owner_labels": ["context"], + "kind_labels": ["bug"], + "state": "open", + "attention": False, + "attention_level": 0, + "attention_marker": "", + "user_interactions": 1, + "new_reactions": 0, + "new_upvotes": 0, + "engagement_score": 3, + "issue_reaction_total": 0, + "comment_reaction_total": 0, + "updated_at": "2026-04-25T01:00:00Z", + "activity": { + "new_issue": True, + "new_comments": 0, + "new_reactions": 0, + "updated_without_visible_new_post": False, + }, + }, + { + "number": 2, + "title": "Busy bug", + "description": "High-volume bug report", + "url": "https://github.com/openai/codex/issues/2", + "owner_labels": ["agent"], + "kind_labels": ["bug"], + "state": "open", + "attention": True, + "attention_level": 1, + "attention_marker": "🔥", + "user_interactions": 17, + "new_reactions": 3, + "new_upvotes": 2, + "engagement_score": 20, + "issue_reaction_total": 5, + "comment_reaction_total": 2, + "updated_at": "2026-04-25T02:00:00Z", + "activity": { + "new_issue": False, + "new_comments": 16, + "new_reactions": 3, + "updated_without_visible_new_post": False, + }, + }, + ] + ) + + assert rows[0] == { + "ref": 1, + "ref_markdown": "[1](https://github.com/openai/codex/issues/2)", + "marker": "🔥", + "attention_marker": "🔥", + "number": 2, + "description": "High-volume bug report", + "title": "Busy bug", + "url": "https://github.com/openai/codex/issues/2", + "area": "agent", + "kind": "bug", + "state": "open", + "interactions": 17, + "user_interactions": 17, + "new_reactions": 3, + "new_upvotes": 2, + "current_reactions": 7, + } + + +def test_summary_inputs_are_model_ready_without_preclustering(): + issues = [ + { + "number": 20, + "title": "Windows app Browser Use external navigation fails", + "description": "Browser Use navigation or app-server failure", + "url": "https://github.com/openai/codex/issues/20", + "labels": ["app", "bug"], + "owner_labels": ["app"], + "kind_labels": ["bug"], + "attention": False, + "attention_level": 0, + "attention_marker": "", + "user_interactions": 3, + "new_reactions": 1, + "engagement_score": 8, + "updated_at": "2026-04-25T04:00:00Z", + "activity": {"new_comments": 2}, + }, + { + "number": 21, + "title": "On Windows, cmake output waits until timeout", + "description": "Windows command timeout/capture problem", + "url": "https://github.com/openai/codex/issues/21", + "labels": ["app", "bug"], + "owner_labels": ["app"], + "kind_labels": ["bug"], + "attention": False, + "attention_level": 0, + "attention_marker": "", + "user_interactions": 3, + "new_reactions": 0, + "engagement_score": 7, + "updated_at": "2026-04-25T03:00:00Z", + "activity": {"new_comments": 3}, + }, + { + "number": 22, + "title": "Windows computer use tool fails to click buttons", + "description": "Computer-use workflow failure", + "url": "https://github.com/openai/codex/issues/22", + "labels": ["app", "bug"], + "owner_labels": ["app"], + "kind_labels": ["bug"], + "attention": False, + "attention_level": 0, + "attention_marker": "", + "user_interactions": 3, + "new_reactions": 0, + "engagement_score": 6, + "updated_at": "2026-04-25T02:00:00Z", + "activity": {"new_comments": 3}, + }, + ] + + rows = collect_issue_digest.summary_inputs(issues, ref_map={20: 1, 21: 2, 22: 3}) + + assert rows == [ + { + "ref": 1, + "ref_markdown": "[1](https://github.com/openai/codex/issues/20)", + "number": 20, + "title": "Windows app Browser Use external navigation fails", + "description": "Browser Use navigation or app-server failure", + "url": "https://github.com/openai/codex/issues/20", + "labels": ["app", "bug"], + "owner_labels": ["app"], + "kind_labels": ["bug"], + "state": "", + "attention_marker": "", + "interactions": 3, + "new_comments": 2, + "new_reactions": 1, + "new_upvotes": 0, + "current_reactions": 0, + }, + { + "ref": 2, + "ref_markdown": "[2](https://github.com/openai/codex/issues/21)", + "number": 21, + "title": "On Windows, cmake output waits until timeout", + "description": "Windows command timeout/capture problem", + "url": "https://github.com/openai/codex/issues/21", + "labels": ["app", "bug"], + "owner_labels": ["app"], + "kind_labels": ["bug"], + "state": "", + "attention_marker": "", + "interactions": 3, + "new_comments": 3, + "new_reactions": 0, + "new_upvotes": 0, + "current_reactions": 0, + }, + { + "ref": 3, + "ref_markdown": "[3](https://github.com/openai/codex/issues/22)", + "number": 22, + "title": "Windows computer use tool fails to click buttons", + "description": "Computer-use workflow failure", + "url": "https://github.com/openai/codex/issues/22", + "labels": ["app", "bug"], + "owner_labels": ["app"], + "kind_labels": ["bug"], + "state": "", + "attention_marker": "", + "interactions": 3, + "new_comments": 3, + "new_reactions": 0, + "new_upvotes": 0, + "current_reactions": 0, + }, + ] diff --git a/.devcontainer/Dockerfile.secure b/.devcontainer/Dockerfile.secure index f5f4d016d9..6c1878eafa 100644 --- a/.devcontainer/Dockerfile.secure +++ b/.devcontainer/Dockerfile.secure @@ -4,9 +4,11 @@ ARG TZ ARG DEBIAN_FRONTEND=noninteractive ARG NODE_MAJOR=22 ARG RUST_TOOLCHAIN=1.92.0 -ARG CODEX_NPM_VERSION=latest +# Keep this in sync with .devcontainer/codex-install/package.json and pnpm-lock.yaml. +ARG CODEX_NPM_VERSION=0.121.0 ENV TZ="$TZ" +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 SHELL ["/bin/bash", "-o", "pipefail", "-c"] @@ -43,12 +45,18 @@ RUN apt-get update \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* +COPY .devcontainer/codex-install/package.json \ + .devcontainer/codex-install/pnpm-lock.yaml \ + .devcontainer/codex-install/pnpm-workspace.yaml \ + /opt/codex-install/ + RUN curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash - \ && apt-get update \ && apt-get install -y --no-install-recommends nodejs \ - && npm install -g corepack@latest "@openai/codex@${CODEX_NPM_VERSION}" \ - && corepack enable \ - && corepack prepare pnpm@10.28.2 --activate \ + && test "$(node -p "require('/opt/codex-install/package.json').dependencies['@openai/codex']")" = "${CODEX_NPM_VERSION}" \ + && cd /opt/codex-install \ + && corepack pnpm install --prod --frozen-lockfile \ + && ln -s /opt/codex-install/node_modules/.bin/codex /usr/local/bin/codex \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* diff --git a/.devcontainer/codex-install/package.json b/.devcontainer/codex-install/package.json new file mode 100644 index 0000000000..453054e20b --- /dev/null +++ b/.devcontainer/codex-install/package.json @@ -0,0 +1,13 @@ +{ + "name": "codex-devcontainer-install", + "private": true, + "description": "Locked Codex CLI install boundary for the secure devcontainer.", + "dependencies": { + "@openai/codex": "0.121.0" + }, + "engines": { + "node": ">=22", + "pnpm": ">=10.33.0" + }, + "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319" +} diff --git a/.devcontainer/codex-install/pnpm-lock.yaml b/.devcontainer/codex-install/pnpm-lock.yaml new file mode 100644 index 0000000000..70e7608ef7 --- /dev/null +++ b/.devcontainer/codex-install/pnpm-lock.yaml @@ -0,0 +1,85 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@openai/codex': + specifier: 0.121.0 + version: 0.121.0 + +packages: + + '@openai/codex@0.121.0': + resolution: {integrity: sha512-kCJ2NeATd4QBQRmqV04ymdN1ZU3MSwnJQDm/KzjpuzGvCuUVEn7no/T2mRyxQ2x77AACqriNOyPPoM/yufyvNg==} + engines: {node: '>=16'} + hasBin: true + + '@openai/codex@0.121.0-darwin-arm64': + resolution: {integrity: sha512-ZyBqIB6Fb4I0hGb/h65Vu7ePYjHSmGiqqfm+/1djEuxDPkqjfi4wkxYxNYNY+6najyNGN4UijOSTTf19eDCrqw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@openai/codex@0.121.0-darwin-x64': + resolution: {integrity: sha512-1/OAtdkAZ5yPI3xqaEFlHuPziS1yCqL2gOZdswE7HTmmwpIxi6Z3FCo60JWDPluIp89z4tftdjq73/OCN0YVcw==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@openai/codex@0.121.0-linux-arm64': + resolution: {integrity: sha512-2UgMmdo237o7SCMsfb529cOSEM2HFUgN6OBkv5SBLwfNY1NO2Ex6JnUjlppEXlX6/4cXfZ5qjDghVz5j/+B9zw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@openai/codex@0.121.0-linux-x64': + resolution: {integrity: sha512-vlpNJXIqss800J+32Vy7TUZzv31n61b45OLxmsVQGFkTNLJcjFrj9jDUC7I62eC4F16gLioilefNfv4CdJQOEw==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@openai/codex@0.121.0-win32-arm64': + resolution: {integrity: sha512-m88q4f3XI5npn1t6OG0nWGHWWAjO5FgjRwxh4hdujbLO6t9CiCNfhfPZIOSsoATbrCNwLC+6S77m3cjbNToPNg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [win32] + + '@openai/codex@0.121.0-win32-x64': + resolution: {integrity: sha512-Fp0ecVOyM+VcBi/y4HVvRzhifO9YqRiHzhV3rhtAppC7flh22WPguLC4kmvXYAR0p3RPzbo35M2CedWnkOT+cw==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + +snapshots: + + '@openai/codex@0.121.0': + optionalDependencies: + '@openai/codex-darwin-arm64': '@openai/codex@0.121.0-darwin-arm64' + '@openai/codex-darwin-x64': '@openai/codex@0.121.0-darwin-x64' + '@openai/codex-linux-arm64': '@openai/codex@0.121.0-linux-arm64' + '@openai/codex-linux-x64': '@openai/codex@0.121.0-linux-x64' + '@openai/codex-win32-arm64': '@openai/codex@0.121.0-win32-arm64' + '@openai/codex-win32-x64': '@openai/codex@0.121.0-win32-x64' + + '@openai/codex@0.121.0-darwin-arm64': + optional: true + + '@openai/codex@0.121.0-darwin-x64': + optional: true + + '@openai/codex@0.121.0-linux-arm64': + optional: true + + '@openai/codex@0.121.0-linux-x64': + optional: true + + '@openai/codex@0.121.0-win32-arm64': + optional: true + + '@openai/codex@0.121.0-win32-x64': + optional: true diff --git a/.devcontainer/codex-install/pnpm-workspace.yaml b/.devcontainer/codex-install/pnpm-workspace.yaml new file mode 100644 index 0000000000..3b901a01d1 --- /dev/null +++ b/.devcontainer/codex-install/pnpm-workspace.yaml @@ -0,0 +1,12 @@ +packages: + - "." + +minimumReleaseAge: 10080 +minimumReleaseAgeExclude: [] + +blockExoticSubdeps: true +strictDepBuilds: true +trustPolicy: no-downgrade +trustPolicyIgnoreAfter: 10080 +trustPolicyExclude: [] +allowBuilds: {} diff --git a/.devcontainer/devcontainer.secure.json b/.devcontainer/devcontainer.secure.json index f52686986c..5d5808e541 100644 --- a/.devcontainer/devcontainer.secure.json +++ b/.devcontainer/devcontainer.secure.json @@ -8,7 +8,7 @@ "TZ": "${localEnv:TZ:UTC}", "NODE_MAJOR": "22", "RUST_TOOLCHAIN": "1.92.0", - "CODEX_NPM_VERSION": "latest" + "CODEX_NPM_VERSION": "0.121.0" } }, "runArgs": [ diff --git a/.gitattributes b/.gitattributes index 0f1c1b413d..57c5fe6e88 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ codex-rs/app-server-protocol/schema/** linguist-generated +codex-rs/hooks/schema/generated/** linguist-generated diff --git a/.github/actions/linux-code-sign/action.yml b/.github/actions/linux-code-sign/action.yml index 12e521187f..f8efb822f8 100644 --- a/.github/actions/linux-code-sign/action.yml +++ b/.github/actions/linux-code-sign/action.yml @@ -7,6 +7,9 @@ inputs: artifacts-dir: description: Absolute path to the directory containing built binaries to sign. required: true + binaries: + description: Space-delimited binary basenames to sign. + default: "codex codex-responses-api-proxy" runs: using: composite @@ -18,6 +21,7 @@ runs: shell: bash env: ARTIFACTS_DIR: ${{ inputs.artifacts-dir }} + BINARIES: ${{ inputs.binaries }} COSIGN_EXPERIMENTAL: "1" COSIGN_YES: "true" COSIGN_OIDC_CLIENT_ID: "sigstore" @@ -31,7 +35,7 @@ runs: exit 1 fi - for binary in codex codex-responses-api-proxy; do + for binary in ${BINARIES}; do artifact="${dest}/${binary}" if [[ ! -f "$artifact" ]]; then echo "Binary $artifact not found" diff --git a/.github/actions/macos-code-sign/action.yml b/.github/actions/macos-code-sign/action.yml index 200b23901f..0e19fa11d0 100644 --- a/.github/actions/macos-code-sign/action.yml +++ b/.github/actions/macos-code-sign/action.yml @@ -4,6 +4,9 @@ inputs: target: description: Rust compilation target triple (e.g. aarch64-apple-darwin). required: true + binaries: + description: Space-delimited binary basenames to sign and notarize. + default: "codex codex-responses-api-proxy" sign-binaries: description: Whether to sign and notarize the macOS binaries. required: false @@ -119,6 +122,7 @@ runs: shell: bash env: TARGET: ${{ inputs.target }} + BINARIES: ${{ inputs.binaries }} run: | set -euo pipefail @@ -134,7 +138,7 @@ runs: entitlements_path="$GITHUB_ACTION_PATH/codex.entitlements.plist" - for binary in codex codex-responses-api-proxy; do + for binary in ${BINARIES}; do path="codex-rs/target/${TARGET}/release/${binary}" codesign --force --options runtime --timestamp --entitlements "$entitlements_path" --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$path" done @@ -144,6 +148,7 @@ runs: shell: bash env: TARGET: ${{ inputs.target }} + BINARIES: ${{ inputs.binaries }} APPLE_NOTARIZATION_KEY_P8: ${{ inputs.apple-notarization-key-p8 }} APPLE_NOTARIZATION_KEY_ID: ${{ inputs.apple-notarization-key-id }} APPLE_NOTARIZATION_ISSUER_ID: ${{ inputs.apple-notarization-issuer-id }} @@ -182,8 +187,9 @@ runs: notarize_submission "$binary" "$archive_path" "$notary_key_path" } - notarize_binary "codex" - notarize_binary "codex-responses-api-proxy" + for binary in ${BINARIES}; do + notarize_binary "${binary}" + done - name: Sign and notarize macOS dmg if: ${{ inputs.sign-dmg == 'true' }} diff --git a/.github/actions/prepare-bazel-ci/action.yml b/.github/actions/prepare-bazel-ci/action.yml index 78f5aeb9a3..48c6ba74b4 100644 --- a/.github/actions/prepare-bazel-ci/action.yml +++ b/.github/actions/prepare-bazel-ci/action.yml @@ -8,7 +8,7 @@ inputs: description: Logical namespace used to keep concurrent Bazel jobs from reserving the same repository cache key. required: true install-test-prereqs: - description: Install Node.js and DotSlash for Bazel-backed test jobs. + description: Install DotSlash for Bazel-backed test jobs. required: false default: "false" outputs: diff --git a/.github/actions/run-argument-comment-lint/action.yml b/.github/actions/run-argument-comment-lint/action.yml new file mode 100644 index 0000000000..80fb23d417 --- /dev/null +++ b/.github/actions/run-argument-comment-lint/action.yml @@ -0,0 +1,54 @@ +name: Run argument comment lint +description: Run argument-comment-lint on codex-rs via Bazel. + +inputs: + target: + description: Runner target passed to setup-bazel-ci. + required: true + buildbuddy-api-key: + description: BuildBuddy API key used by Bazel CI. + required: false + default: "" + +runs: + using: composite + steps: + - uses: ./.github/actions/setup-bazel-ci + with: + target: ${{ inputs.target }} + install-test-prereqs: true + + - name: Install Linux sandbox build dependencies + if: ${{ runner.os == 'Linux' }} + shell: bash + run: | + sudo DEBIAN_FRONTEND=noninteractive apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev + + - name: Run argument comment lint on codex-rs via Bazel + if: ${{ runner.os != 'Windows' }} + env: + BUILDBUDDY_API_KEY: ${{ inputs.buildbuddy-api-key }} + shell: bash + run: | + bazel_targets="$(./tools/argument-comment-lint/list-bazel-targets.sh)" + ./.github/scripts/run-bazel-ci.sh \ + -- \ + build \ + --config=argument-comment-lint \ + --keep_going \ + --build_metadata=COMMIT_SHA=${GITHUB_SHA} \ + -- \ + ${bazel_targets} + + - name: Run argument comment lint on codex-rs via Bazel + if: ${{ runner.os == 'Windows' }} + env: + BUILDBUDDY_API_KEY: ${{ inputs.buildbuddy-api-key }} + shell: bash + run: | + ./.github/scripts/run-argument-comment-lint-bazel.sh \ + --config=argument-comment-lint \ + --platforms=//:local_windows \ + --keep_going \ + --build_metadata=COMMIT_SHA=${GITHUB_SHA} diff --git a/.github/actions/setup-bazel-ci/action.yml b/.github/actions/setup-bazel-ci/action.yml index 7c605c60b7..bb757aab91 100644 --- a/.github/actions/setup-bazel-ci/action.yml +++ b/.github/actions/setup-bazel-ci/action.yml @@ -5,7 +5,7 @@ inputs: description: Target triple used for cache namespacing. required: true install-test-prereqs: - description: Install Node.js and DotSlash for Bazel-backed test jobs. + description: Install DotSlash for Bazel-backed test jobs. required: false default: "false" outputs: @@ -16,12 +16,6 @@ outputs: runs: using: composite steps: - - name: Set up Node.js for js_repl tests - if: inputs.install-test-prereqs == 'true' - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 - with: - node-version-file: codex-rs/node-version.txt - # Some integration tests rely on DotSlash being installed. # See https://github.com/openai/codex/pull/7617. - name: Install DotSlash @@ -39,7 +33,7 @@ runs: run: Copy-Item (Get-Command dotslash).Source -Destination "$env:LOCALAPPDATA\Microsoft\WindowsApps\dotslash.exe" - name: Set up Bazel - uses: bazelbuild/setup-bazelisk@b39c379c82683a5f25d34f0d062761f62693e0b2 # v3 + uses: bazel-contrib/setup-bazel@c5acdfb288317d0b5c0bbd7a396a3dc868bb0f86 # 0.19.0 - name: Configure Bazel repository cache id: configure_bazel_repository_cache @@ -122,6 +116,11 @@ runs: } } + - name: Compute cache-stable Windows Bazel PATH + if: runner.os == 'Windows' + shell: pwsh + run: ./.github/scripts/compute-bazel-windows-path.ps1 + - name: Enable Git long paths (Windows) if: runner.os == 'Windows' shell: pwsh diff --git a/.github/actions/windows-code-sign/action.yml b/.github/actions/windows-code-sign/action.yml index b79c790f16..6289fa917d 100644 --- a/.github/actions/windows-code-sign/action.yml +++ b/.github/actions/windows-code-sign/action.yml @@ -4,6 +4,9 @@ inputs: target: description: Target triple for the artifacts to sign. required: true + binaries: + description: Space-delimited binary basenames to sign. + default: "codex codex-responses-api-proxy codex-windows-sandbox-setup codex-command-runner" client-id: description: Azure Trusted Signing client ID. required: true @@ -33,6 +36,23 @@ runs: tenant-id: ${{ inputs.tenant-id }} subscription-id: ${{ inputs.subscription-id }} + - name: Prepare file list + id: prepare + shell: bash + env: + TARGET: ${{ inputs.target }} + BINARIES: ${{ inputs.binaries }} + run: | + set -euo pipefail + + { + echo "files<> "$GITHUB_OUTPUT" + - name: Sign Windows binaries with Azure Trusted Signing uses: azure/trusted-signing-action@1d365fec12862c4aa68fcac418143d73f0cea293 # v0 with: @@ -50,8 +70,4 @@ runs: exclude-azure-developer-cli-credential: true exclude-interactive-browser-credential: true cache-dependencies: false - files: | - ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex.exe - ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex-responses-api-proxy.exe - ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex-windows-sandbox-setup.exe - ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex-command-runner.exe + files: ${{ steps.prepare.outputs.files }} diff --git a/.github/dotslash-config.json b/.github/dotslash-config.json index 00e9032cf1..5caef01e85 100644 --- a/.github/dotslash-config.json +++ b/.github/dotslash-config.json @@ -28,6 +28,34 @@ } } }, + "codex-app-server": { + "platforms": { + "macos-aarch64": { + "regex": "^codex-app-server-aarch64-apple-darwin\\.zst$", + "path": "codex-app-server" + }, + "macos-x86_64": { + "regex": "^codex-app-server-x86_64-apple-darwin\\.zst$", + "path": "codex-app-server" + }, + "linux-x86_64": { + "regex": "^codex-app-server-x86_64-unknown-linux-musl\\.zst$", + "path": "codex-app-server" + }, + "linux-aarch64": { + "regex": "^codex-app-server-aarch64-unknown-linux-musl\\.zst$", + "path": "codex-app-server" + }, + "windows-x86_64": { + "regex": "^codex-app-server-x86_64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-app-server.exe" + }, + "windows-aarch64": { + "regex": "^codex-app-server-aarch64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-app-server.exe" + } + } + }, "codex-responses-api-proxy": { "platforms": { "macos-aarch64": { diff --git a/.github/scripts/compute-bazel-windows-path.ps1 b/.github/scripts/compute-bazel-windows-path.ps1 new file mode 100644 index 0000000000..6b6bbe0462 --- /dev/null +++ b/.github/scripts/compute-bazel-windows-path.ps1 @@ -0,0 +1,105 @@ +<# +BuildBuddy cache keys include the action and test environment, so Bazel should +not inherit the full hosted-runner PATH on Windows. That PATH includes volatile +tool entries, such as Maven, that can change independently of this repo and +cause avoidable cache misses. + +This script derives a smaller, cache-stable PATH that keeps the Windows +toolchain entries Bazel-backed CI tasks need: MSVC and Windows SDK paths, Git, +PowerShell, Node, Python, DotSlash, and the standard Windows system +directories. +`setup-bazel-ci` runs this after exporting the MSVC environment, and the script +publishes the result via `GITHUB_ENV` as `CODEX_BAZEL_WINDOWS_PATH` so later +steps can pass that explicit PATH to Bazel. +#> + +$stablePathEntries = New-Object System.Collections.Generic.List[string] +$seenEntries = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +$windowsAppsPath = if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + $null +} else { + "$($env:LOCALAPPDATA)\Microsoft\WindowsApps" +} +$windowsDir = if ($env:WINDIR) { + $env:WINDIR +} elseif ($env:SystemRoot) { + $env:SystemRoot +} else { + $null +} + +function Add-StablePathEntry { + param([string]$PathEntry) + + if ([string]::IsNullOrWhiteSpace($PathEntry)) { + return + } + + if ($seenEntries.Add($PathEntry)) { + [void]$stablePathEntries.Add($PathEntry) + } +} + +foreach ($pathEntry in ($env:PATH -split ';')) { + if ([string]::IsNullOrWhiteSpace($pathEntry)) { + continue + } + + if ( + $pathEntry -like '*Microsoft Visual Studio*' -or + $pathEntry -like '*Windows Kits*' -or + $pathEntry -like '*Microsoft SDKs*' -or + $pathEntry -like 'C:\Program Files\Git\*' -or + $pathEntry -like 'C:\Program Files\PowerShell\*' -or + $pathEntry -like 'C:\hostedtoolcache\windows\node\*' -or + $pathEntry -like 'C:\hostedtoolcache\windows\Python\*' -or + $pathEntry -eq 'D:\a\_temp\install-dotslash\bin' -or + ($windowsDir -and ($pathEntry -eq $windowsDir -or $pathEntry -like "${windowsDir}\*")) + ) { + Add-StablePathEntry $pathEntry + } +} + +$gitCommand = Get-Command git -ErrorAction SilentlyContinue +if ($gitCommand) { + Add-StablePathEntry (Split-Path $gitCommand.Source -Parent) +} + +$nodeCommand = Get-Command node -ErrorAction SilentlyContinue +if ($nodeCommand) { + Add-StablePathEntry (Split-Path $nodeCommand.Source -Parent) +} + +$python3Command = Get-Command python3 -ErrorAction SilentlyContinue +if ($python3Command) { + Add-StablePathEntry (Split-Path $python3Command.Source -Parent) +} + +$pythonCommand = Get-Command python -ErrorAction SilentlyContinue +if ($pythonCommand) { + Add-StablePathEntry (Split-Path $pythonCommand.Source -Parent) +} + +$pwshCommand = Get-Command pwsh -ErrorAction SilentlyContinue +if ($pwshCommand) { + Add-StablePathEntry (Split-Path $pwshCommand.Source -Parent) +} + +if ($windowsAppsPath) { + Add-StablePathEntry $windowsAppsPath +} + +if ($stablePathEntries.Count -eq 0) { + throw 'Failed to derive cache-stable Windows PATH.' +} + +if ([string]::IsNullOrWhiteSpace($env:GITHUB_ENV)) { + throw 'GITHUB_ENV must be set.' +} + +$stablePath = $stablePathEntries -join ';' +Write-Host 'Derived CODEX_BAZEL_WINDOWS_PATH entries:' +foreach ($pathEntry in $stablePathEntries) { + Write-Host " $pathEntry" +} +"CODEX_BAZEL_WINDOWS_PATH=$stablePath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append diff --git a/.github/scripts/run-argument-comment-lint-bazel.sh b/.github/scripts/run-argument-comment-lint-bazel.sh index e2f494d620..fddca4cadb 100755 --- a/.github/scripts/run-argument-comment-lint-bazel.sh +++ b/.github/scripts/run-argument-comment-lint-bazel.sh @@ -2,16 +2,6 @@ set -euo pipefail -ci_config=ci-linux -case "${RUNNER_OS:-}" in - macOS) - ci_config=ci-macos - ;; - Windows) - ci_config=ci-windows - ;; -esac - bazel_lint_args=("$@") if [[ "${RUNNER_OS:-}" == "Windows" ]]; then has_host_platform_override=0 @@ -44,29 +34,6 @@ if [[ "${RUNNER_OS:-}" == "Windows" ]]; then bazel_lint_args+=("--skip_incompatible_explicit_targets") fi -bazel_startup_args=() -if [[ -n "${BAZEL_OUTPUT_USER_ROOT:-}" ]]; then - bazel_startup_args+=("--output_user_root=${BAZEL_OUTPUT_USER_ROOT}") -fi - -run_bazel() { - if [[ "${RUNNER_OS:-}" == "Windows" ]]; then - MSYS2_ARG_CONV_EXCL='*' bazel "$@" - return - fi - - bazel "$@" -} - -run_bazel_with_startup_args() { - if [[ ${#bazel_startup_args[@]} -gt 0 ]]; then - run_bazel "${bazel_startup_args[@]}" "$@" - return - fi - - run_bazel "$@" -} - read_query_labels() { local query="$1" local query_stdout @@ -74,12 +41,10 @@ read_query_labels() { query_stdout="$(mktemp)" query_stderr="$(mktemp)" - if ! run_bazel_with_startup_args \ - --noexperimental_remote_repo_contents_cache \ - query \ + if ! ./.github/scripts/run-bazel-query-ci.sh \ --keep_going \ --output=label \ - "$query" >"$query_stdout" 2>"$query_stderr"; then + -- "$query" >"$query_stdout" 2>"$query_stderr"; then cat "$query_stderr" >&2 rm -f "$query_stdout" "$query_stderr" exit 1 diff --git a/.github/scripts/run-bazel-ci.sh b/.github/scripts/run-bazel-ci.sh index e5376a812a..b81e0a4d57 100755 --- a/.github/scripts/run-bazel-ci.sh +++ b/.github/scripts/run-bazel-ci.sh @@ -4,7 +4,6 @@ set -euo pipefail print_failed_bazel_test_logs=0 print_failed_bazel_action_summary=0 -use_node_test_env=0 remote_download_toplevel=0 windows_msvc_host_platform=0 @@ -18,10 +17,6 @@ while [[ $# -gt 0 ]]; do print_failed_bazel_action_summary=1 shift ;; - --use-node-test-env) - use_node_test_env=1 - shift - ;; --remote-download-toplevel) remote_download_toplevel=1 shift @@ -42,7 +37,7 @@ while [[ $# -gt 0 ]]; do done if [[ $# -eq 0 ]]; then - echo "Usage: $0 [--print-failed-test-logs] [--print-failed-action-summary] [--use-node-test-env] [--remote-download-toplevel] [--windows-msvc-host-platform] -- -- " >&2 + echo "Usage: $0 [--print-failed-test-logs] [--print-failed-action-summary] [--remote-download-toplevel] [--windows-msvc-host-platform] -- -- " >&2 exit 1 fi @@ -249,16 +244,6 @@ if [[ ${#bazel_args[@]} -eq 0 || ${#bazel_targets[@]} -eq 0 ]]; then exit 1 fi -if [[ $use_node_test_env -eq 1 ]]; then - # Bazel test sandboxes on macOS may resolve an older Homebrew `node` - # before the `actions/setup-node` runtime on PATH. - node_bin="$(which node)" - if [[ "${RUNNER_OS:-}" == "Windows" ]]; then - node_bin="$(cygpath -w "${node_bin}")" - fi - bazel_args+=("--test_env=CODEX_JS_REPL_NODE_PATH=${node_bin}") -fi - post_config_bazel_args=() if [[ "${RUNNER_OS:-}" == "Windows" && $windows_msvc_host_platform -eq 1 ]]; then has_host_platform_override=0 @@ -306,7 +291,6 @@ if [[ "${RUNNER_OS:-}" == "Windows" ]]; then INCLUDE LIB LIBPATH - PATH UCRTVersion UniversalCRTSdkDir VCINSTALLDIR @@ -323,6 +307,17 @@ if [[ "${RUNNER_OS:-}" == "Windows" ]]; then post_config_bazel_args+=("--action_env=${env_var}" "--host_action_env=${env_var}") fi done + + if [[ -z "${CODEX_BAZEL_WINDOWS_PATH:-}" ]]; then + echo "CODEX_BAZEL_WINDOWS_PATH must be set for Windows Bazel CI." >&2 + exit 1 + fi + + post_config_bazel_args+=( + "--action_env=PATH=${CODEX_BAZEL_WINDOWS_PATH}" + "--host_action_env=PATH=${CODEX_BAZEL_WINDOWS_PATH}" + "--test_env=PATH=${CODEX_BAZEL_WINDOWS_PATH}" + ) fi bazel_console_log="$(mktemp)" diff --git a/.github/scripts/run-bazel-query-ci.sh b/.github/scripts/run-bazel-query-ci.sh new file mode 100755 index 0000000000..1ed664e44b --- /dev/null +++ b/.github/scripts/run-bazel-query-ci.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Run Bazel queries with the same CI startup settings as the main build/test +# invocation so target-discovery queries can reuse the same Bazel server. + +query_args=() +while [[ $# -gt 0 ]]; do + case "$1" in + --) + shift + break + ;; + *) + query_args+=("$1") + shift + ;; + esac +done + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 [...] -- " >&2 + exit 1 +fi + +query_expression="$1" + +ci_config=ci-linux +case "${RUNNER_OS:-}" in + macOS) + ci_config=ci-macos + ;; + Windows) + ci_config=ci-windows + ;; +esac + +bazel_startup_args=() +if [[ -n "${BAZEL_OUTPUT_USER_ROOT:-}" ]]; then + bazel_startup_args+=("--output_user_root=${BAZEL_OUTPUT_USER_ROOT}") +fi + +run_bazel() { + if [[ "${RUNNER_OS:-}" == "Windows" ]]; then + MSYS2_ARG_CONV_EXCL='*' bazel "$@" + return + fi + + bazel "$@" +} + +bazel_query_args=(--noexperimental_remote_repo_contents_cache query) +if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then + bazel_query_args+=( + "--config=${ci_config}" + "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" + ) +fi + +if [[ -n "${BAZEL_REPO_CONTENTS_CACHE:-}" ]]; then + bazel_query_args+=("--repo_contents_cache=${BAZEL_REPO_CONTENTS_CACHE}") +fi + +if [[ -n "${BAZEL_REPOSITORY_CACHE:-}" ]]; then + bazel_query_args+=("--repository_cache=${BAZEL_REPOSITORY_CACHE}") +fi + +bazel_query_args+=("${query_args[@]}" "$query_expression") + +if (( ${#bazel_startup_args[@]} > 0 )); then + run_bazel "${bazel_startup_args[@]}" "${bazel_query_args[@]}" +else + run_bazel "${bazel_query_args[@]}" +fi diff --git a/.github/workflows/Dockerfile.bazel b/.github/workflows/Dockerfile.bazel index 4f85409f94..51c199dcc3 100644 --- a/.github/workflows/Dockerfile.bazel +++ b/.github/workflows/Dockerfile.bazel @@ -8,25 +8,9 @@ FROM ubuntu:24.04 RUN apt-get update && \ apt-get install -y --no-install-recommends \ - curl git python3 ca-certificates xz-utils && \ + curl git python3 ca-certificates && \ rm -rf /var/lib/apt/lists/* -COPY codex-rs/node-version.txt /tmp/node-version.txt - -RUN set -eux; \ - node_arch="$(dpkg --print-architecture)"; \ - case "${node_arch}" in \ - amd64) node_dist_arch="x64" ;; \ - arm64) node_dist_arch="arm64" ;; \ - *) echo "unsupported architecture: ${node_arch}"; exit 1 ;; \ - esac; \ - node_version="$(tr -d '[:space:]' > "$GITHUB_OUTPUT" + exit 0 + fi + + echo "No argument-comment-lint relevant changes." + echo "run=false" >> "$GITHUB_OUTPUT" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: ./.github/actions/setup-bazel-ci + if: ${{ steps.argument_comment_lint_gate.outputs.run == 'true' }} + - name: Run argument comment lint on codex-rs via Bazel + if: ${{ steps.argument_comment_lint_gate.outputs.run == 'true' }} + uses: ./.github/actions/run-argument-comment-lint with: target: ${{ runner.os }} - install-test-prereqs: true - - name: Install Linux sandbox build dependencies - if: ${{ runner.os == 'Linux' }} - shell: bash - run: | - sudo DEBIAN_FRONTEND=noninteractive apt-get update - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - - name: Run argument comment lint on codex-rs via Bazel - if: ${{ runner.os != 'Windows' }} - env: - BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} - shell: bash - run: | - bazel_targets="$(./tools/argument-comment-lint/list-bazel-targets.sh)" - ./.github/scripts/run-bazel-ci.sh \ - -- \ - build \ - --config=argument-comment-lint \ - --keep_going \ - --build_metadata=COMMIT_SHA=${GITHUB_SHA} \ - -- \ - ${bazel_targets} - - name: Run argument comment lint on codex-rs via Bazel - if: ${{ runner.os == 'Windows' }} - env: - BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} - shell: bash - run: | - ./.github/scripts/run-argument-comment-lint-bazel.sh \ - --config=argument-comment-lint \ - --platforms=//:local_windows \ - --keep_going \ - --build_metadata=COMMIT_SHA=${GITHUB_SHA} + buildbuddy-api-key: ${{ secrets.BUILDBUDDY_API_KEY }} # --- Gatherer job that you mark as the ONLY required status ----------------- results: diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index f1aee51911..7a4843243a 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -40,28 +40,42 @@ jobs: - runner: windows-x64 target: x86_64-pc-windows-msvc bundle: primary - build_args: --bin codex --bin codex-responses-api-proxy + binaries: "codex codex-responses-api-proxy" runs_on: group: codex-runners labels: codex-windows-x64 - runner: windows-arm64 target: aarch64-pc-windows-msvc bundle: primary - build_args: --bin codex --bin codex-responses-api-proxy + binaries: "codex codex-responses-api-proxy" runs_on: group: codex-runners labels: codex-windows-arm64 - runner: windows-x64 target: x86_64-pc-windows-msvc bundle: helpers - build_args: --bin codex-windows-sandbox-setup --bin codex-command-runner + binaries: "codex-windows-sandbox-setup codex-command-runner" runs_on: group: codex-runners labels: codex-windows-x64 - runner: windows-arm64 target: aarch64-pc-windows-msvc bundle: helpers - build_args: --bin codex-windows-sandbox-setup --bin codex-command-runner + binaries: "codex-windows-sandbox-setup codex-command-runner" + runs_on: + group: codex-runners + labels: codex-windows-arm64 + - runner: windows-x64 + target: x86_64-pc-windows-msvc + bundle: app-server + binaries: "codex-app-server" + runs_on: + group: codex-runners + labels: codex-windows-x64 + - runner: windows-arm64 + target: aarch64-pc-windows-msvc + bundle: app-server + binaries: "codex-app-server" runs_on: group: codex-runners labels: codex-windows-arm64 @@ -89,7 +103,11 @@ jobs: - name: Cargo build (Windows binaries) shell: bash run: | - cargo build --target ${{ matrix.target }} --release --timings ${{ matrix.build_args }} + build_args=() + for binary in ${{ matrix.binaries }}; do + build_args+=(--bin "$binary") + done + cargo build --target ${{ matrix.target }} --release --timings "${build_args[@]}" - name: Upload Cargo timings uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 @@ -103,13 +121,9 @@ jobs: run: | output_dir="target/${{ matrix.target }}/release/staged-${{ matrix.bundle }}" mkdir -p "$output_dir" - if [[ "${{ matrix.bundle }}" == "primary" ]]; then - cp target/${{ matrix.target }}/release/codex.exe "$output_dir/codex.exe" - cp target/${{ matrix.target }}/release/codex-responses-api-proxy.exe "$output_dir/codex-responses-api-proxy.exe" - else - cp target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe "$output_dir/codex-windows-sandbox-setup.exe" - cp target/${{ matrix.target }}/release/codex-command-runner.exe "$output_dir/codex-command-runner.exe" - fi + for binary in ${{ matrix.binaries }}; do + cp "target/${{ matrix.target }}/release/${binary}.exe" "$output_dir/${binary}.exe" + done - name: Upload Windows binaries uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 @@ -130,6 +144,8 @@ jobs: defaults: run: working-directory: codex-rs + env: + WINDOWS_BINARIES: "codex codex-responses-api-proxy codex-windows-sandbox-setup codex-command-runner codex-app-server" strategy: fail-fast: false @@ -161,19 +177,25 @@ jobs: name: windows-binaries-${{ matrix.target }}-helpers path: codex-rs/target/${{ matrix.target }}/release + - name: Download prebuilt Windows app-server binary + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: windows-binaries-${{ matrix.target }}-app-server + path: codex-rs/target/${{ matrix.target }}/release + - name: Verify binaries shell: bash run: | set -euo pipefail - ls -lh target/${{ matrix.target }}/release/codex.exe - ls -lh target/${{ matrix.target }}/release/codex-responses-api-proxy.exe - ls -lh target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe - ls -lh target/${{ matrix.target }}/release/codex-command-runner.exe + for binary in ${WINDOWS_BINARIES}; do + ls -lh "target/${{ matrix.target }}/release/${binary}.exe" + done - name: Sign Windows binaries with Azure Trusted Signing uses: ./.github/actions/windows-code-sign with: target: ${{ matrix.target }} + binaries: ${{ env.WINDOWS_BINARIES }} client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} @@ -187,10 +209,10 @@ jobs: dest="dist/${{ matrix.target }}" mkdir -p "$dest" - cp target/${{ matrix.target }}/release/codex.exe "$dest/codex-${{ matrix.target }}.exe" - cp target/${{ matrix.target }}/release/codex-responses-api-proxy.exe "$dest/codex-responses-api-proxy-${{ matrix.target }}.exe" - cp target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe "$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" - cp target/${{ matrix.target }}/release/codex-command-runner.exe "$dest/codex-command-runner-${{ matrix.target }}.exe" + for binary in ${WINDOWS_BINARIES}; do + cp "target/${{ matrix.target }}/release/${binary}.exe" \ + "$dest/${binary}-${{ matrix.target }}.exe" + done - name: Install DotSlash uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index efd3dd11eb..d2c2cfdba7 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -47,7 +47,7 @@ jobs: build: needs: tag-check - name: Build - ${{ matrix.runner }} - ${{ matrix.target }} + name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} runs-on: ${{ matrix.runs_on || matrix.runner }} timeout-minutes: 60 permissions: @@ -67,16 +67,53 @@ jobs: include: - runner: macos-15-xlarge target: aarch64-apple-darwin + bundle: primary + artifact_name: aarch64-apple-darwin + binaries: "codex codex-responses-api-proxy" + build_dmg: "true" + - runner: macos-15-xlarge + target: aarch64-apple-darwin + bundle: app-server + artifact_name: aarch64-apple-darwin-app-server + binaries: "codex-app-server" + build_dmg: "false" - runner: macos-15-xlarge target: x86_64-apple-darwin + bundle: primary + artifact_name: x86_64-apple-darwin + binaries: "codex codex-responses-api-proxy" + build_dmg: "true" + - runner: macos-15-xlarge + target: x86_64-apple-darwin + bundle: app-server + artifact_name: x86_64-apple-darwin-app-server + binaries: "codex-app-server" + build_dmg: "false" + # Release artifacts intentionally ship MUSL-linked Linux binaries. - runner: ubuntu-24.04 target: x86_64-unknown-linux-musl + bundle: primary + artifact_name: x86_64-unknown-linux-musl + binaries: "codex codex-responses-api-proxy" + build_dmg: "false" - runner: ubuntu-24.04 - target: x86_64-unknown-linux-gnu + target: x86_64-unknown-linux-musl + bundle: app-server + artifact_name: x86_64-unknown-linux-musl-app-server + binaries: "codex-app-server" + build_dmg: "false" - runner: ubuntu-24.04-arm target: aarch64-unknown-linux-musl + bundle: primary + artifact_name: aarch64-unknown-linux-musl + binaries: "codex codex-responses-api-proxy" + build_dmg: "false" - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-gnu + target: aarch64-unknown-linux-musl + bundle: app-server + artifact_name: aarch64-unknown-linux-musl-app-server + binaries: "codex-app-server" + build_dmg: "false" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -219,13 +256,17 @@ jobs: - name: Cargo build shell: bash run: | + build_args=() + for binary in ${{ matrix.binaries }}; do + build_args+=(--bin "$binary") + done echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}" - cargo build --target ${{ matrix.target }} --release --timings --bin codex --bin codex-responses-api-proxy + cargo build --target ${{ matrix.target }} --release --timings "${build_args[@]}" - name: Upload Cargo timings uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: - name: cargo-timings-rust-release-${{ matrix.target }} + name: cargo-timings-rust-release-${{ matrix.target }}-${{ matrix.bundle }} path: codex-rs/target/**/cargo-timings/cargo-timing.html if-no-files-found: warn @@ -235,12 +276,14 @@ jobs: with: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release + binaries: ${{ matrix.binaries }} - if: ${{ runner.os == 'macOS' }} name: MacOS code signing (binaries) uses: ./.github/actions/macos-code-sign with: target: ${{ matrix.target }} + binaries: ${{ matrix.binaries }} sign-binaries: "true" sign-dmg: "false" apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }} @@ -249,7 +292,7 @@ jobs: apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - - if: ${{ runner.os == 'macOS' }} + - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' }} name: Build macOS dmg shell: bash run: | @@ -264,23 +307,17 @@ jobs: # The previous "MacOS code signing (binaries)" step signs + notarizes the # built artifacts in `${release_dir}`. This step packages *those same* # signed binaries into a dmg. - codex_binary_path="${release_dir}/codex" - proxy_binary_path="${release_dir}/codex-responses-api-proxy" - rm -rf "$dmg_root" mkdir -p "$dmg_root" - if [[ ! -f "$codex_binary_path" ]]; then - echo "Binary $codex_binary_path not found" - exit 1 - fi - if [[ ! -f "$proxy_binary_path" ]]; then - echo "Binary $proxy_binary_path not found" - exit 1 - fi - - ditto "$codex_binary_path" "${dmg_root}/codex" - ditto "$proxy_binary_path" "${dmg_root}/codex-responses-api-proxy" + for binary in ${{ matrix.binaries }}; do + binary_path="${release_dir}/${binary}" + if [[ ! -f "${binary_path}" ]]; then + echo "Binary ${binary_path} not found" + exit 1 + fi + ditto "${binary_path}" "${dmg_root}/${binary}" + done rm -f "$dmg_path" hdiutil create \ @@ -295,7 +332,7 @@ jobs: exit 1 fi - - if: ${{ runner.os == 'macOS' }} + - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' }} name: MacOS code signing (dmg) uses: ./.github/actions/macos-code-sign with: @@ -314,15 +351,15 @@ jobs: dest="dist/${{ matrix.target }}" mkdir -p "$dest" - cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}" - cp target/${{ matrix.target }}/release/codex-responses-api-proxy "$dest/codex-responses-api-proxy-${{ matrix.target }}" + for binary in ${{ matrix.binaries }}; do + cp "target/${{ matrix.target }}/release/${binary}" "$dest/${binary}-${{ matrix.target }}" + if [[ "${{ matrix.target }}" == *linux* ]]; then + cp "target/${{ matrix.target }}/release/${binary}.sigstore" \ + "$dest/${binary}-${{ matrix.target }}.sigstore" + fi + done - if [[ "${{ matrix.target }}" == *linux* ]]; then - cp target/${{ matrix.target }}/release/codex.sigstore "$dest/codex-${{ matrix.target }}.sigstore" - cp target/${{ matrix.target }}/release/codex-responses-api-proxy.sigstore "$dest/codex-responses-api-proxy-${{ matrix.target }}.sigstore" - fi - - if [[ "${{ matrix.target }}" == *apple-darwin ]]; then + if [[ "${{ matrix.build_dmg }}" == "true" ]]; then cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" fi @@ -364,7 +401,7 @@ jobs: - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: - name: ${{ matrix.target }} + name: ${{ matrix.artifact_name }} # Upload the per-binary .zst files as well as the new .tar.gz # equivalents we generated in the previous step. path: | @@ -614,11 +651,59 @@ jobs: prefix="${NPM_TAG}-" fi + root_tarball="dist/npm/codex-npm-${VERSION}.tgz" + sdk_tarball="dist/npm/codex-sdk-npm-${VERSION}.tgz" + # Keep this list in sync with CODEX_PLATFORM_PACKAGES in + # codex-cli/scripts/build_npm_package.py. The root wrapper advances + # @openai/codex@latest as soon as it publishes, so every platform + # package it aliases must already exist in the registry first. + platform_tarballs=( + "dist/npm/codex-npm-linux-x64-${VERSION}.tgz" + "dist/npm/codex-npm-linux-arm64-${VERSION}.tgz" + "dist/npm/codex-npm-darwin-x64-${VERSION}.tgz" + "dist/npm/codex-npm-darwin-arm64-${VERSION}.tgz" + "dist/npm/codex-npm-win32-x64-${VERSION}.tgz" + "dist/npm/codex-npm-win32-arm64-${VERSION}.tgz" + ) + + for required_tarball in "${platform_tarballs[@]}" "${root_tarball}"; do + if [[ ! -f "${required_tarball}" ]]; then + echo "Missing npm tarball: ${required_tarball}" + exit 1 + fi + done + shopt -s nullglob - tarballs=(dist/npm/*-"${VERSION}".tgz) - if [[ ${#tarballs[@]} -eq 0 ]]; then - echo "No npm tarballs found in dist/npm for version ${VERSION}" - exit 1 + other_tarballs=() + for tarball in dist/npm/*-"${VERSION}".tgz; do + if [[ "${tarball}" == "${root_tarball}" || "${tarball}" == "${sdk_tarball}" ]]; then + continue + fi + + is_platform_tarball=false + for platform_tarball in "${platform_tarballs[@]}"; do + if [[ "${tarball}" == "${platform_tarball}" ]]; then + is_platform_tarball=true + break + fi + done + if [[ "${is_platform_tarball}" == true ]]; then + continue + fi + + other_tarballs+=("${tarball}") + done + + # Publish the platform packages before the root CLI wrapper. The root + # wrapper advances @openai/codex@latest, so it should only publish + # after the optional dependency versions it references exist. + tarballs=( + "${platform_tarballs[@]}" + "${other_tarballs[@]}" + "${root_tarball}" + ) + if [[ -f "${sdk_tarball}" ]]; then + tarballs+=("${sdk_tarball}") fi for tarball in "${tarballs[@]}"; do diff --git a/.github/workflows/rusty-v8-release.yml b/.github/workflows/rusty-v8-release.yml index 29e7b3b1ae..ee92eff4fa 100644 --- a/.github/workflows/rusty-v8-release.yml +++ b/.github/workflows/rusty-v8-release.yml @@ -78,7 +78,9 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Bazel - uses: bazelbuild/setup-bazelisk@b39c379c82683a5f25d34f0d062761f62693e0b2 # v3 + uses: ./.github/actions/setup-bazel-ci + with: + target: ${{ matrix.target }} - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index f5aa1d7c67..119d042275 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -3,6 +3,7 @@ name: v8-canary on: pull_request: paths: + - ".github/actions/setup-bazel-ci/**" - ".github/scripts/rusty_v8_bazel.py" - ".github/workflows/rusty-v8-release.yml" - ".github/workflows/v8-canary.yml" @@ -16,6 +17,7 @@ on: branches: - main paths: + - ".github/actions/setup-bazel-ci/**" - ".github/scripts/rusty_v8_bazel.py" - ".github/workflows/rusty-v8-release.yml" - ".github/workflows/v8-canary.yml" @@ -75,7 +77,9 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Bazel - uses: bazelbuild/setup-bazelisk@b39c379c82683a5f25d34f0d062761f62693e0b2 # v3 + uses: ./.github/actions/setup-bazel-ci + with: + target: ${{ matrix.target }} - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 diff --git a/.gitignore b/.gitignore index 82269594bb..f792773a27 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,7 @@ yarn-error.log* # env .env* !.env.example +.venv/ # package *.tgz @@ -91,4 +92,3 @@ CHANGELOG.ignore.md # Python bytecode files __pycache__/ *.pyc - diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index f5ad018c03..7c0b30feba 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -660,14 +660,34 @@ "atoi_2.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.14\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"num-traits/std\"]}}", "atomic-waker_1.1.2": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.5\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.7.0\"}],\"features\":{}}", "autocfg_1.5.0": "{\"dependencies\":[],\"features\":{}}", + "aws-config_1.8.12": "{\"dependencies\":[{\"features\":[\"test-util\"],\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"name\":\"aws-runtime\",\"req\":\"^1.5.17\"},{\"default_features\":false,\"name\":\"aws-sdk-signin\",\"optional\":true,\"req\":\"^1.2.0\"},{\"default_features\":false,\"name\":\"aws-sdk-sso\",\"optional\":true,\"req\":\"^1.91.0\"},{\"default_features\":false,\"name\":\"aws-sdk-ssooidc\",\"optional\":true,\"req\":\"^1.93.0\"},{\"default_features\":false,\"name\":\"aws-sdk-sts\",\"req\":\"^1.95.0\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"features\":[\"rt-tokio\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.62.6\"},{\"features\":[\"default-client\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-http-client\",\"req\":\"^1.1.5\"},{\"name\":\"aws-smithy-json\",\"req\":\"^0.61.8\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime\",\"req\":\"^1.9.5\"},{\"features\":[\"client\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime\",\"req\":\"^1.9.5\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"aws-types\",\"req\":\"^1.3.11\"},{\"name\":\"base64-simd\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"bytes\",\"req\":\"^1.1.0\"},{\"name\":\"fastrand\",\"req\":\"^2.3.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.29\"},{\"name\":\"hex\",\"optional\":true,\"req\":\"^0.4.3\"},{\"name\":\"http\",\"req\":\"^1\"},{\"name\":\"p256\",\"optional\":true,\"req\":\"^0.13.2\"},{\"default_features\":false,\"features\":[\"std\",\"std_rng\"],\"name\":\"rand\",\"optional\":true,\"req\":\"^0.8.5\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17.5\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.9\"},{\"features\":[\"parsing\"],\"name\":\"time\",\"req\":\"^0.3.4\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.13.1\"},{\"features\":[\"full\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"fmt\",\"json\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.16\"},{\"kind\":\"dev\",\"name\":\"tracing-test\",\"req\":\"^0.2.4\"},{\"name\":\"url\",\"req\":\"^2.5.4\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.18.1\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"allow-compilation\":[],\"behavior-version-latest\":[],\"client-hyper\":[\"aws-smithy-runtime/default-https-client\"],\"credentials-login\":[\"dep:aws-sdk-signin\",\"dep:sha2\",\"dep:zeroize\",\"dep:hex\",\"dep:base64-simd\",\"dep:uuid\",\"uuid?/v4\",\"dep:p256\",\"p256?/arithmetic\",\"p256?/pem\",\"dep:rand\"],\"credentials-process\":[\"tokio/process\"],\"default\":[\"default-https-client\",\"rt-tokio\",\"credentials-process\",\"sso\"],\"default-https-client\":[\"aws-smithy-runtime/default-https-client\"],\"rt-tokio\":[\"aws-smithy-async/rt-tokio\",\"aws-smithy-runtime/rt-tokio\",\"tokio/rt\"],\"rustls\":[\"client-hyper\"],\"sso\":[\"dep:aws-sdk-sso\",\"dep:aws-sdk-ssooidc\",\"dep:ring\",\"dep:hex\",\"dep:zeroize\",\"aws-smithy-runtime-api/http-auth\"],\"test-util\":[\"aws-runtime/test-util\"]}}", + "aws-credential-types_1.2.11": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1.74\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"features\":[\"client\",\"http-auth\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"features\":[\"full\",\"test-util\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"name\":\"zeroize\",\"req\":\"^1.7.0\"}],\"features\":{\"hardcoded-credentials\":[],\"test-util\":[\"aws-smithy-runtime-api/test-util\"]}}", "aws-lc-rs_1.16.2": "{\"dependencies\":[{\"name\":\"aws-lc-fips-sys\",\"optional\":true,\"req\":\"^0.13.1\"},{\"default_features\":false,\"name\":\"aws-lc-sys\",\"optional\":true,\"req\":\"^0.39.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.4\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.15\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.11.1\"},{\"name\":\"untrusted\",\"optional\":true,\"req\":\"^0.7.1\"},{\"name\":\"zeroize\",\"req\":\"^1.8.1\"}],\"features\":{\"alloc\":[],\"asan\":[\"aws-lc-sys?/asan\",\"aws-lc-fips-sys?/asan\"],\"bindgen\":[\"aws-lc-sys?/bindgen\",\"aws-lc-fips-sys?/bindgen\"],\"default\":[\"aws-lc-sys\",\"alloc\",\"ring-io\",\"ring-sig-verify\"],\"dev-tests-only\":[],\"fips\":[\"dep:aws-lc-fips-sys\"],\"non-fips\":[\"aws-lc-sys\"],\"prebuilt-nasm\":[\"aws-lc-sys?/prebuilt-nasm\"],\"ring-io\":[\"dep:untrusted\"],\"ring-sig-verify\":[\"dep:untrusted\"],\"test_logging\":[],\"unstable\":[]}}", "aws-lc-sys_0.39.0": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72.0\"},{\"features\":[\"parallel\"],\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.26\"},{\"kind\":\"build\",\"name\":\"cmake\",\"req\":\"^0.1.54\"},{\"kind\":\"build\",\"name\":\"dunce\",\"req\":\"^1.0.5\"},{\"kind\":\"build\",\"name\":\"fs_extra\",\"req\":\"^1.3.0\"}],\"features\":{\"all-bindings\":[],\"asan\":[],\"bindgen\":[\"dep:bindgen\"],\"default\":[\"all-bindings\"],\"disable-prebuilt-nasm\":[],\"fips\":[\"dep:bindgen\"],\"prebuilt-nasm\":[],\"ssl\":[\"bindgen\",\"all-bindings\"]}}", + "aws-runtime_1.5.17": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"arbitrary\",\"req\":\"^1.3\"},{\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"features\":[\"http0-compat\"],\"name\":\"aws-sigv4\",\"req\":\"^1.3.7\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"name\":\"aws-smithy-eventstream\",\"optional\":true,\"req\":\"^0.60.14\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.62.6\"},{\"kind\":\"dev\",\"name\":\"aws-smithy-protocol-test\",\"req\":\"^0.63.7\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime\",\"req\":\"^1.9.5\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"aws-types\",\"req\":\"^1.3.11\"},{\"name\":\"bytes\",\"req\":\"^1.10.0\"},{\"kind\":\"dev\",\"name\":\"bytes-utils\",\"req\":\"^0.1.2\"},{\"kind\":\"dev\",\"name\":\"convert_case\",\"req\":\"^0.6.0\"},{\"name\":\"fastrand\",\"req\":\"^2.3.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.29\"},{\"name\":\"http-02x\",\"package\":\"http\",\"req\":\"^0.2.9\"},{\"name\":\"http-1x\",\"optional\":true,\"package\":\"http\",\"req\":\"^1.1.0\"},{\"name\":\"http-body-04x\",\"package\":\"http-body\",\"req\":\"^0.4.5\"},{\"name\":\"http-body-1x\",\"optional\":true,\"package\":\"http-body\",\"req\":\"^1.0.0\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.2\"},{\"name\":\"regex-lite\",\"optional\":true,\"req\":\"^0.1.5\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"features\":[\"macros\",\"rt\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"name\":\"tracing\",\"req\":\"^0.1.40\"},{\"features\":[\"env-filter\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.17\"},{\"kind\":\"dev\",\"name\":\"tracing-test\",\"req\":\"^0.2.4\"},{\"name\":\"uuid\",\"req\":\"^1\"}],\"features\":{\"event-stream\":[\"dep:aws-smithy-eventstream\",\"aws-sigv4/sign-eventstream\"],\"http-02x\":[],\"http-1x\":[\"dep:http-1x\",\"dep:http-body-1x\"],\"sigv4a\":[\"aws-sigv4/sigv4a\"],\"test-util\":[\"dep:regex-lite\"]}}", + "aws-sdk-sso_1.91.0": "{\"dependencies\":[{\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"name\":\"aws-runtime\",\"req\":\"^1.5.17\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.62.6\"},{\"name\":\"aws-smithy-json\",\"req\":\"^0.61.8\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime\",\"req\":\"^1.9.5\"},{\"features\":[\"client\",\"http-02x\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"aws-types\",\"req\":\"^1.3.11\"},{\"name\":\"bytes\",\"req\":\"^1.4.0\"},{\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"http\",\"req\":\"^0.2.9\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"regex-lite\",\"req\":\"^0.1.5\"},{\"features\":[\"macros\",\"test-util\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"name\":\"tracing\",\"req\":\"^0.1\"}],\"features\":{\"behavior-version-latest\":[],\"default\":[\"rustls\",\"default-https-client\",\"rt-tokio\"],\"default-https-client\":[\"aws-smithy-runtime/default-https-client\"],\"gated-tests\":[],\"rt-tokio\":[\"aws-smithy-async/rt-tokio\",\"aws-smithy-types/rt-tokio\"],\"rustls\":[\"aws-smithy-runtime/tls-rustls\"],\"test-util\":[\"aws-credential-types/test-util\",\"aws-smithy-runtime/test-util\"]}}", + "aws-sdk-ssooidc_1.93.0": "{\"dependencies\":[{\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"name\":\"aws-runtime\",\"req\":\"^1.5.17\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.62.6\"},{\"name\":\"aws-smithy-json\",\"req\":\"^0.61.8\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime\",\"req\":\"^1.9.5\"},{\"features\":[\"client\",\"http-02x\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"aws-types\",\"req\":\"^1.3.11\"},{\"name\":\"bytes\",\"req\":\"^1.4.0\"},{\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"http\",\"req\":\"^0.2.9\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"regex-lite\",\"req\":\"^0.1.5\"},{\"features\":[\"macros\",\"test-util\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"name\":\"tracing\",\"req\":\"^0.1\"}],\"features\":{\"behavior-version-latest\":[],\"default\":[\"rustls\",\"default-https-client\",\"rt-tokio\"],\"default-https-client\":[\"aws-smithy-runtime/default-https-client\"],\"gated-tests\":[],\"rt-tokio\":[\"aws-smithy-async/rt-tokio\",\"aws-smithy-types/rt-tokio\"],\"rustls\":[\"aws-smithy-runtime/tls-rustls\"],\"test-util\":[\"aws-credential-types/test-util\",\"aws-smithy-runtime/test-util\"]}}", + "aws-sdk-sts_1.95.0": "{\"dependencies\":[{\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"name\":\"aws-runtime\",\"req\":\"^1.5.17\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-runtime\",\"req\":\"^1.5.17\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.62.6\"},{\"features\":[\"test-util\",\"wire-mock\"],\"kind\":\"dev\",\"name\":\"aws-smithy-http-client\",\"req\":\"^1.1.5\"},{\"name\":\"aws-smithy-json\",\"req\":\"^0.61.8\"},{\"kind\":\"dev\",\"name\":\"aws-smithy-protocol-test\",\"req\":\"^0.63.7\"},{\"name\":\"aws-smithy-query\",\"req\":\"^0.60.9\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime\",\"req\":\"^1.9.5\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime\",\"req\":\"^1.9.5\"},{\"features\":[\"client\",\"http-02x\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"aws-smithy-xml\",\"req\":\"^0.60.13\"},{\"name\":\"aws-types\",\"req\":\"^1.3.11\"},{\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.25\"},{\"name\":\"http\",\"req\":\"^0.2.9\"},{\"kind\":\"dev\",\"name\":\"http-1x\",\"package\":\"http\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"regex-lite\",\"req\":\"^0.1.5\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.0\"},{\"features\":[\"macros\",\"test-util\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"json\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.16\"}],\"features\":{\"behavior-version-latest\":[],\"default\":[\"rustls\",\"default-https-client\",\"rt-tokio\"],\"default-https-client\":[\"aws-smithy-runtime/default-https-client\"],\"gated-tests\":[],\"rt-tokio\":[\"aws-smithy-async/rt-tokio\",\"aws-smithy-types/rt-tokio\"],\"rustls\":[\"aws-smithy-runtime/tls-rustls\"],\"test-util\":[\"aws-credential-types/test-util\",\"aws-smithy-runtime/test-util\"]}}", + "aws-sigv4_1.3.7": "{\"dependencies\":[{\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"features\":[\"test-util\",\"hardcoded-credentials\"],\"kind\":\"dev\",\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"name\":\"aws-smithy-eventstream\",\"optional\":true,\"req\":\"^0.60.14\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.62.6\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"features\":[\"client\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"bytes\",\"req\":\"^1.10.0\"},{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"crypto-bigint\",\"optional\":true,\"req\":\"^0.5.4\"},{\"name\":\"form_urlencoded\",\"optional\":true,\"req\":\"^1.2.1\"},{\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4.1\"},{\"name\":\"hmac\",\"req\":\"^0.12\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1.1.0\"},{\"name\":\"http0\",\"optional\":true,\"package\":\"http\",\"req\":\"^0.2.9\"},{\"kind\":\"dev\",\"name\":\"httparse\",\"req\":\"^1.10.1\"},{\"features\":[\"ecdsa\"],\"name\":\"p256\",\"optional\":true,\"req\":\"^0.11\"},{\"name\":\"percent-encoding\",\"optional\":true,\"req\":\"^2.3.1\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.3\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.2\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17.5\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17.5\",\"target\":\"cfg(not(any(target_arch = \\\"powerpc\\\", target_arch = \\\"powerpc64\\\")))\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.180\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.180\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.104\"},{\"name\":\"sha2\",\"req\":\"^0.10\"},{\"name\":\"subtle\",\"optional\":true,\"req\":\"^2.5.0\"},{\"name\":\"time\",\"req\":\"^0.3.5\"},{\"features\":[\"parsing\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.5\"},{\"name\":\"tracing\",\"req\":\"^0.1.40\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.7.0\"}],\"features\":{\"default\":[\"sign-http\",\"http1\"],\"http0-compat\":[\"dep:http0\"],\"http1\":[\"dep:http\"],\"sign-eventstream\":[\"dep:aws-smithy-eventstream\"],\"sign-http\":[\"dep:http0\",\"dep:percent-encoding\",\"dep:form_urlencoded\"],\"sigv4a\":[\"dep:p256\",\"dep:crypto-bigint\",\"dep:subtle\",\"dep:zeroize\",\"dep:ring\"]}}", + "aws-smithy-async_1.2.14": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-util\",\"req\":\"^0.3.29\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"kind\":\"dev\",\"name\":\"pin-utils\",\"req\":\"^0.1\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.49.0\"},{\"features\":[\"rt\",\"macros\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.49.0\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.2\"}],\"features\":{\"rt-tokio\":[\"tokio/time\"],\"test-util\":[\"rt-tokio\",\"tokio/rt\"]}}", + "aws-smithy-http-client_1.1.12": "{\"dependencies\":[{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.14\"},{\"features\":[\"rt-tokio\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-async\",\"req\":\"^1.2.14\"},{\"name\":\"aws-smithy-protocol-test\",\"optional\":true,\"req\":\"^0.63.14\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.11.6\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.11.6\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.4.6\"},{\"features\":[\"http-body-0-4-x\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-types\",\"req\":\"^1.4.6\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.11.1\"},{\"default_features\":false,\"name\":\"h2\",\"req\":\"^0.4.11\"},{\"name\":\"h2-0-3\",\"optional\":true,\"package\":\"h2\",\"req\":\"^0.3.24\"},{\"name\":\"http-02x\",\"optional\":true,\"package\":\"http\",\"req\":\"^0.2.12\"},{\"name\":\"http-1x\",\"optional\":true,\"package\":\"http\",\"req\":\"^1.3.1\"},{\"name\":\"http-body-04x\",\"optional\":true,\"package\":\"http-body\",\"req\":\"^0.4.6\"},{\"name\":\"http-body-1x\",\"optional\":true,\"package\":\"http-body\",\"req\":\"^1.0.1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1.3\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1.3\"},{\"features\":[\"client\",\"http1\",\"http2\"],\"name\":\"hyper\",\"optional\":true,\"req\":\"^1.6.0\"},{\"default_features\":false,\"features\":[\"client\",\"http1\",\"http2\",\"tcp\",\"stream\"],\"name\":\"hyper-0-14\",\"optional\":true,\"package\":\"hyper\",\"req\":\"^0.14.26\"},{\"default_features\":false,\"features\":[\"http2\",\"http1\",\"native-tokio\",\"tls12\"],\"name\":\"hyper-rustls\",\"optional\":true,\"req\":\"^0.27\"},{\"features\":[\"http1\",\"http2\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1.16\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.16\"},{\"features\":[\"serde\"],\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.10.0\"},{\"default_features\":false,\"features\":[\"http1\",\"tls12\",\"logging\",\"acceptor\",\"tokio-runtime\",\"http2\"],\"name\":\"legacy-hyper-rustls\",\"optional\":true,\"package\":\"hyper-rustls\",\"req\":\"^0.24.2\"},{\"name\":\"legacy-rustls\",\"optional\":true,\"package\":\"rustls\",\"req\":\"^0.21.8\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"default_features\":false,\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.31\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.1\"},{\"kind\":\"dev\",\"name\":\"rustls-pemfile\",\"req\":\"^2.2.0\"},{\"features\":[\"std\"],\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.12.0\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"rustls-pki-types\",\"req\":\"^1.12.0\"},{\"name\":\"s2n-tls\",\"optional\":true,\"req\":\"^0.3.33\"},{\"name\":\"s2n-tls-hyper\",\"optional\":true,\"req\":\"^0.1.0\"},{\"name\":\"s2n-tls-tokio\",\"optional\":true,\"req\":\"^0.3.33\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.228\"},{\"features\":[\"preserve_order\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.146\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3.2\"},{\"name\":\"tokio\",\"req\":\"^1.49\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\",\"test-util\",\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.49.0\"},{\"default_features\":false,\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26.2\"},{\"kind\":\"dev\",\"name\":\"tokio-rustls\",\"req\":\"^0.26.2\"},{\"name\":\"tower\",\"optional\":true,\"req\":\"^0.5.2\"},{\"name\":\"tracing\",\"req\":\"^0.1.44\"}],\"features\":{\"default-client\":[\"aws-smithy-runtime-api/http-1x\",\"aws-smithy-types/http-body-1-x\",\"dep:hyper\",\"dep:hyper-util\",\"hyper-util?/client-legacy\",\"hyper-util?/client-proxy\",\"dep:http-1x\",\"dep:tower\",\"dep:rustls-pki-types\",\"dep:rustls-native-certs\"],\"hyper-014\":[\"aws-smithy-runtime-api/http-02x\",\"aws-smithy-types/http-body-0-4-x\",\"dep:http-02x\",\"dep:http-body-04x\",\"dep:hyper-0-14\",\"dep:h2-0-3\"],\"legacy-rustls-ring\":[\"dep:legacy-hyper-rustls\",\"dep:legacy-rustls\",\"dep:rustls-native-certs\",\"hyper-014\"],\"legacy-test-util\":[\"test-util\",\"dep:http-02x\",\"aws-smithy-runtime-api/http-02x\",\"aws-smithy-types/http-body-0-4-x\"],\"rustls-aws-lc\":[\"dep:rustls\",\"rustls?/aws_lc_rs\",\"rustls?/prefer-post-quantum\",\"dep:hyper-rustls\",\"dep:tokio-rustls\",\"default-client\"],\"rustls-aws-lc-fips\":[\"dep:rustls\",\"rustls?/fips\",\"rustls?/prefer-post-quantum\",\"dep:hyper-rustls\",\"dep:tokio-rustls\",\"default-client\"],\"rustls-ring\":[\"dep:rustls\",\"rustls?/ring\",\"dep:hyper-rustls\",\"dep:tokio-rustls\",\"default-client\"],\"s2n-tls\":[\"dep:s2n-tls\",\"dep:s2n-tls-hyper\",\"dep:s2n-tls-tokio\",\"default-client\"],\"test-util\":[\"dep:aws-smithy-protocol-test\",\"dep:serde\",\"dep:serde_json\",\"dep:indexmap\",\"dep:bytes\",\"dep:http-1x\",\"aws-smithy-runtime-api/http-1x\",\"dep:http-body-1x\",\"aws-smithy-types/http-body-1-x\",\"tokio/rt\"],\"wire-mock\":[\"test-util\",\"default-client\",\"hyper-util?/server\",\"hyper-util?/server-auto\",\"hyper-util?/service\",\"hyper-util?/server-graceful\",\"tokio/macros\",\"dep:http-body-util\"]}}", + "aws-smithy-http_0.62.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"name\":\"aws-smithy-eventstream\",\"optional\":true,\"req\":\"^0.60.14\"},{\"features\":[\"client\",\"http-02x\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"features\":[\"byte-stream-poll-next\",\"http-body-0-4-x\"],\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"bytes\",\"req\":\"^1.10.0\"},{\"name\":\"bytes-utils\",\"req\":\"^0.1\"},{\"name\":\"futures-core\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-util\",\"req\":\"^0.3.29\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.29\"},{\"name\":\"http-02x\",\"package\":\"http\",\"req\":\"^0.2.9\"},{\"name\":\"http-1x\",\"package\":\"http\",\"req\":\"^1\"},{\"name\":\"http-body-04x\",\"package\":\"http-body\",\"req\":\"^0.4.5\"},{\"features\":[\"stream\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^0.14.26\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"name\":\"pin-utils\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"name\":\"tracing\",\"req\":\"^0.1.40\"}],\"features\":{\"event-stream\":[\"aws-smithy-eventstream\"],\"rt-tokio\":[\"aws-smithy-types/rt-tokio\"]}}", + "aws-smithy-json_0.61.9": "{\"dependencies\":[{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{}}", + "aws-smithy-observability_0.1.5": "{\"dependencies\":[{\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3.1.1\"}],\"features\":{}}", + "aws-smithy-query_0.60.9": "{\"dependencies\":[{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"urlencoding\",\"req\":\"^2.1\"}],\"features\":{}}", + "aws-smithy-runtime-api-macros_1.0.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.106\"},{\"name\":\"quote\",\"req\":\"^1.0.44\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.114\"}],\"features\":{}}", + "aws-smithy-runtime-api_1.12.0": "{\"dependencies\":[{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.14\"},{\"name\":\"aws-smithy-runtime-api-macros\",\"req\":\"^1.0.0\"},{\"features\":[\"http-body-1-x\"],\"name\":\"aws-smithy-types\",\"req\":\"^1.4.7\"},{\"name\":\"bytes\",\"req\":\"^1.11.1\"},{\"name\":\"http-02x\",\"package\":\"http\",\"req\":\"^0.2.12\"},{\"name\":\"http-1x\",\"package\":\"http\",\"req\":\"^1.3.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.49.0\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.49.0\"},{\"name\":\"tracing\",\"req\":\"^0.1.44\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.7.0\"}],\"features\":{\"client\":[],\"default\":[],\"http-02x\":[],\"http-1x\":[],\"http-auth\":[\"dep:zeroize\"],\"test-util\":[\"aws-smithy-types/test-util\",\"http-1x\"]}}", + "aws-smithy-runtime_1.9.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"approx\",\"req\":\"^0.5.1\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"features\":[\"rt-tokio\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.62.6\"},{\"name\":\"aws-smithy-http-client\",\"optional\":true,\"req\":\"^1.1.5\"},{\"name\":\"aws-smithy-observability\",\"req\":\"^0.1.5\"},{\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"features\":[\"http-body-0-4-x\"],\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"bytes\",\"req\":\"^1.10.0\"},{\"name\":\"fastrand\",\"req\":\"^2.3.0\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.29\"},{\"name\":\"http-02x\",\"package\":\"http\",\"req\":\"^0.2.9\"},{\"name\":\"http-1x\",\"package\":\"http\",\"req\":\"^1\"},{\"name\":\"http-body-04x\",\"package\":\"http-body\",\"req\":\"^0.4.5\"},{\"name\":\"http-body-1x\",\"package\":\"http-body\",\"req\":\"^1\"},{\"features\":[\"client\",\"server\",\"tcp\",\"http1\",\"http2\"],\"kind\":\"dev\",\"name\":\"hyper_0_14\",\"package\":\"hyper\",\"req\":\"^0.14.27\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"name\":\"pin-utils\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4.0\"},{\"name\":\"tokio\",\"req\":\"^1.40.0\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\",\"test-util\",\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.25\"},{\"name\":\"tracing\",\"req\":\"^0.1.40\"},{\"features\":[\"env-filter\",\"fmt\",\"json\"],\"name\":\"tracing-subscriber\",\"optional\":true,\"req\":\"^0.3.16\"},{\"features\":[\"env-filter\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.16\"},{\"kind\":\"dev\",\"name\":\"tracing-test\",\"req\":\"^0.2.1\"}],\"features\":{\"client\":[\"aws-smithy-runtime-api/client\",\"aws-smithy-types/http-body-1-x\"],\"connector-hyper-0-14-x\":[\"dep:aws-smithy-http-client\",\"aws-smithy-http-client?/hyper-014\"],\"default-https-client\":[\"dep:aws-smithy-http-client\",\"aws-smithy-http-client?/rustls-aws-lc\"],\"http-auth\":[\"aws-smithy-runtime-api/http-auth\"],\"legacy-test-util\":[\"aws-smithy-runtime-api/test-util\",\"dep:tracing-subscriber\",\"aws-smithy-http-client/test-util\",\"connector-hyper-0-14-x\",\"aws-smithy-http-client/legacy-test-util\"],\"rt-tokio\":[\"tokio/rt\"],\"test-util\":[\"aws-smithy-runtime-api/test-util\",\"dep:tracing-subscriber\",\"aws-smithy-http-client/test-util\",\"legacy-test-util\"],\"tls-rustls\":[\"dep:aws-smithy-http-client\",\"aws-smithy-http-client?/legacy-rustls-ring\",\"connector-hyper-0-14-x\"],\"wire-mock\":[\"legacy-test-util\",\"aws-smithy-http-client/wire-mock\"]}}", + "aws-smithy-types_1.4.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.13.0\"},{\"name\":\"base64-simd\",\"req\":\"^0.8\"},{\"name\":\"bytes\",\"req\":\"^1.11.1\"},{\"name\":\"bytes-utils\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"ciborium\",\"req\":\"^0.2.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3.31\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^0.2.12\"},{\"name\":\"http-1x\",\"package\":\"http\",\"req\":\"^1.3.1\"},{\"name\":\"http-body-0-4\",\"optional\":true,\"package\":\"http-body\",\"req\":\"^0.4.6\"},{\"name\":\"http-body-1-0\",\"optional\":true,\"package\":\"http-body\",\"req\":\"^1.0.1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1.3\"},{\"name\":\"hyper-0-14\",\"optional\":true,\"package\":\"hyper\",\"req\":\"^0.14.26\"},{\"name\":\"itoa\",\"req\":\"^1.0.17\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"name\":\"num-integer\",\"req\":\"^0.1.44\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"name\":\"pin-utils\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"name\":\"ryu\",\"req\":\"^1.0.22\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.228\",\"target\":\"cfg(aws_sdk_unstable)\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.16.0\"},{\"features\":[\"parsing\"],\"name\":\"time\",\"req\":\"^0.3.4\"},{\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.49.0\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\",\"fs\",\"io-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.49.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1.5\"},{\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.18\"}],\"features\":{\"byte-stream-poll-next\":[],\"http-body-0-4-x\":[\"dep:http-body-0-4\",\"dep:http\"],\"http-body-1-x\":[\"dep:http-body-1-0\",\"dep:http-body-util\",\"dep:http-body-0-4\",\"dep:http\"],\"hyper-0-14-x\":[\"dep:hyper-0-14\"],\"rt-tokio\":[\"dep:http-body-0-4\",\"dep:tokio-util\",\"dep:tokio\",\"tokio?/rt\",\"tokio?/fs\",\"tokio?/io-util\",\"tokio-util?/io\",\"dep:futures-core\",\"dep:http\"],\"serde-deserialize\":[],\"serde-serialize\":[],\"test-util\":[]}}", + "aws-smithy-xml_0.60.13": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"aws-smithy-protocol-test\",\"req\":\"^0.63.7\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.13.0\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"xmlparser\",\"req\":\"^0.13.5\"}],\"features\":{}}", + "aws-types_1.3.11": "{\"dependencies\":[{\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"name\":\"aws-smithy-runtime\",\"optional\":true,\"req\":\"^1.9.5\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"features\":[\"http-02x\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"kind\":\"dev\",\"name\":\"http\",\"req\":\"^0.2.4\"},{\"default_features\":false,\"features\":[\"http2\",\"webpki-roots\"],\"name\":\"hyper-rustls\",\"optional\":true,\"req\":\"^0.24.2\"},{\"kind\":\"build\",\"name\":\"rustc_version\",\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.16.0\"},{\"features\":[\"rt\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tracing\",\"req\":\"^0.1.40\"},{\"kind\":\"dev\",\"name\":\"tracing-test\",\"req\":\"^0.2.5\"}],\"features\":{\"examples\":[\"dep:hyper-rustls\",\"aws-smithy-runtime/client\",\"aws-smithy-runtime/connector-hyper-0-14-x\",\"aws-smithy-runtime/tls-rustls\"]}}", "axum-core_0.4.5": "{\"dependencies\":[{\"name\":\"async-trait\",\"req\":\"^0.1.67\"},{\"kind\":\"dev\",\"name\":\"axum\",\"req\":\"^0.7.2\"},{\"name\":\"bytes\",\"req\":\"^1.2\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1.0.0\"},{\"name\":\"http-body\",\"req\":\"^1.0.0\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.0.0\"},{\"name\":\"mime\",\"req\":\"^0.3.16\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.7\"},{\"name\":\"rustversion\",\"req\":\"^1.0.9\"},{\"name\":\"sync_wrapper\",\"req\":\"^1.0.0\"},{\"features\":[\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.25.0\"},{\"features\":[\"limit\"],\"name\":\"tower-http\",\"optional\":true,\"req\":\"^0.6.0\"},{\"features\":[\"limit\"],\"kind\":\"dev\",\"name\":\"tower-http\",\"req\":\"^0.6.0\"},{\"name\":\"tower-layer\",\"req\":\"^0.3\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.37\"}],\"features\":{\"__private_docs\":[\"dep:tower-http\"],\"tracing\":[\"dep:tracing\"]}}", "axum-core_0.5.6": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1.2\"},{\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1.0.0\"},{\"name\":\"http-body\",\"req\":\"^1.0.0\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.0.0\"},{\"name\":\"mime\",\"req\":\"^0.3.16\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.7\"},{\"name\":\"sync_wrapper\",\"req\":\"^1.0.0\"},{\"features\":[\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.25.0\"},{\"features\":[\"limit\"],\"name\":\"tower-http\",\"optional\":true,\"req\":\"^0.6.0\"},{\"features\":[\"limit\"],\"kind\":\"dev\",\"name\":\"tower-http\",\"req\":\"^0.6.0\"},{\"name\":\"tower-layer\",\"req\":\"^0.3\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.37\"}],\"features\":{\"__private_docs\":[\"dep:tower-http\"],\"tracing\":[\"dep:tracing\"]}}", "axum_0.7.9": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"async-trait\",\"req\":\"^0.1.67\"},{\"name\":\"axum-core\",\"req\":\"^0.4.5\"},{\"name\":\"axum-macros\",\"optional\":true,\"req\":\"^0.4.2\"},{\"features\":[\"__private\"],\"kind\":\"dev\",\"name\":\"axum-macros\",\"req\":\"^0.4.1\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22.1\"},{\"name\":\"bytes\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1.0.0\"},{\"name\":\"http-body\",\"req\":\"^1.0.0\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"name\":\"hyper\",\"optional\":true,\"req\":\"^1.1.0\"},{\"features\":[\"tokio\",\"server\",\"service\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1.3\"},{\"name\":\"itoa\",\"req\":\"^1.0.5\"},{\"name\":\"matchit\",\"req\":\"^0.7\"},{\"name\":\"memchr\",\"req\":\"^2.4.1\"},{\"name\":\"mime\",\"req\":\"^0.3.16\"},{\"name\":\"multer\",\"optional\":true,\"req\":\"^3.0.0\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.7\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"json\",\"stream\",\"multipart\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.12\"},{\"name\":\"rustversion\",\"req\":\"^1.0.9\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"raw_value\"],\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_path_to_error\",\"optional\":true,\"req\":\"^0.1.8\"},{\"name\":\"serde_urlencoded\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"sync_wrapper\",\"req\":\"^1.0.0\"},{\"features\":[\"serde-human-readable\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\"},{\"features\":[\"time\"],\"name\":\"tokio\",\"optional\":true,\"package\":\"tokio\",\"req\":\"^1.25.0\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\",\"net\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"package\":\"tokio\",\"req\":\"^1.25.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"name\":\"tokio-tungstenite\",\"optional\":true,\"req\":\"^0.24.0\"},{\"kind\":\"dev\",\"name\":\"tokio-tungstenite\",\"req\":\"^0.24.0\"},{\"default_features\":false,\"features\":[\"util\"],\"name\":\"tower\",\"req\":\"^0.5.1\"},{\"features\":[\"util\",\"timeout\",\"limit\",\"load-shed\",\"steer\",\"filter\"],\"kind\":\"dev\",\"name\":\"tower\",\"package\":\"tower\",\"req\":\"^0.5.1\"},{\"features\":[\"add-extension\",\"auth\",\"catch-panic\",\"compression-br\",\"compression-deflate\",\"compression-gzip\",\"cors\",\"decompression-br\",\"decompression-deflate\",\"decompression-gzip\",\"follow-redirect\",\"fs\",\"limit\",\"map-request-body\",\"map-response-body\",\"metrics\",\"normalize-path\",\"propagate-header\",\"redirect\",\"request-id\",\"sensitive-headers\",\"set-header\",\"set-status\",\"timeout\",\"trace\",\"util\",\"validate-request\"],\"name\":\"tower-http\",\"optional\":true,\"req\":\"^0.6.0\"},{\"features\":[\"add-extension\",\"auth\",\"catch-panic\",\"compression-br\",\"compression-deflate\",\"compression-gzip\",\"cors\",\"decompression-br\",\"decompression-deflate\",\"decompression-gzip\",\"follow-redirect\",\"fs\",\"limit\",\"map-request-body\",\"map-response-body\",\"metrics\",\"normalize-path\",\"propagate-header\",\"redirect\",\"request-id\",\"sensitive-headers\",\"set-header\",\"set-status\",\"timeout\",\"trace\",\"util\",\"validate-request\"],\"kind\":\"dev\",\"name\":\"tower-http\",\"req\":\"^0.6.0\"},{\"name\":\"tower-layer\",\"req\":\"^0.3.2\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"json\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"features\":[\"serde\",\"v4\"],\"kind\":\"dev\",\"name\":\"uuid\",\"req\":\"^1.0\"}],\"features\":{\"__private_docs\":[\"axum-core/__private_docs\",\"tower/full\",\"dep:tower-http\"],\"default\":[\"form\",\"http1\",\"json\",\"matched-path\",\"original-uri\",\"query\",\"tokio\",\"tower-log\",\"tracing\"],\"form\":[\"dep:serde_urlencoded\"],\"http1\":[\"dep:hyper\",\"hyper?/http1\",\"hyper-util?/http1\"],\"http2\":[\"dep:hyper\",\"hyper?/http2\",\"hyper-util?/http2\"],\"json\":[\"dep:serde_json\",\"dep:serde_path_to_error\"],\"macros\":[\"dep:axum-macros\"],\"matched-path\":[],\"multipart\":[\"dep:multer\"],\"original-uri\":[],\"query\":[\"dep:serde_urlencoded\"],\"tokio\":[\"dep:hyper-util\",\"dep:tokio\",\"tokio/net\",\"tokio/rt\",\"tower/make\",\"tokio/macros\"],\"tower-log\":[\"tower/log\"],\"tracing\":[\"dep:tracing\",\"axum-core/tracing\"],\"ws\":[\"dep:hyper\",\"tokio\",\"dep:tokio-tungstenite\",\"dep:sha1\",\"dep:base64\"]}}", "axum_0.8.8": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"axum-core\",\"req\":\"^0.5.5\"},{\"name\":\"axum-macros\",\"optional\":true,\"req\":\"^0.5.0\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22.1\"},{\"name\":\"bytes\",\"req\":\"^1.0\"},{\"name\":\"form_urlencoded\",\"optional\":true,\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1.0.0\"},{\"name\":\"http-body\",\"req\":\"^1.0.0\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"name\":\"hyper\",\"optional\":true,\"req\":\"^1.1.0\"},{\"features\":[\"client\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1.0\"},{\"features\":[\"tokio\",\"server\",\"service\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1.3\"},{\"name\":\"itoa\",\"req\":\"^1.0.5\"},{\"name\":\"matchit\",\"req\":\"=0.8.4\"},{\"name\":\"memchr\",\"req\":\"^2.4.1\"},{\"name\":\"mime\",\"req\":\"^0.3.16\"},{\"name\":\"multer\",\"optional\":true,\"req\":\"^3.0.0\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.7\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"json\",\"stream\",\"multipart\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.12\"},{\"default_features\":false,\"features\":[\"json\",\"stream\",\"multipart\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.12\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.211\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.221\"},{\"name\":\"serde_core\",\"req\":\"^1.0.221\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"raw_value\"],\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_path_to_error\",\"optional\":true,\"req\":\"^0.1.8\"},{\"name\":\"serde_urlencoded\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"sync_wrapper\",\"req\":\"^1.0.0\"},{\"features\":[\"serde-human-readable\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\"},{\"features\":[\"time\"],\"name\":\"tokio\",\"optional\":true,\"package\":\"tokio\",\"req\":\"^1.44\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\",\"net\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"package\":\"tokio\",\"req\":\"^1.44.2\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"name\":\"tokio-tungstenite\",\"optional\":true,\"req\":\"^0.28.0\"},{\"kind\":\"dev\",\"name\":\"tokio-tungstenite\",\"req\":\"^0.28.0\"},{\"default_features\":false,\"features\":[\"util\"],\"name\":\"tower\",\"req\":\"^0.5.2\"},{\"features\":[\"util\",\"timeout\",\"limit\",\"load-shed\",\"steer\",\"filter\"],\"kind\":\"dev\",\"name\":\"tower\",\"package\":\"tower\",\"req\":\"^0.5.2\"},{\"features\":[\"add-extension\",\"auth\",\"catch-panic\",\"compression-br\",\"compression-deflate\",\"compression-gzip\",\"cors\",\"decompression-br\",\"decompression-deflate\",\"decompression-gzip\",\"follow-redirect\",\"fs\",\"limit\",\"map-request-body\",\"map-response-body\",\"metrics\",\"normalize-path\",\"propagate-header\",\"redirect\",\"request-id\",\"sensitive-headers\",\"set-header\",\"set-status\",\"timeout\",\"trace\",\"util\",\"validate-request\"],\"name\":\"tower-http\",\"optional\":true,\"req\":\"^0.6.0\"},{\"features\":[\"add-extension\",\"auth\",\"catch-panic\",\"compression-br\",\"compression-deflate\",\"compression-gzip\",\"cors\",\"decompression-br\",\"decompression-deflate\",\"decompression-gzip\",\"follow-redirect\",\"fs\",\"limit\",\"map-request-body\",\"map-response-body\",\"metrics\",\"normalize-path\",\"propagate-header\",\"redirect\",\"request-id\",\"sensitive-headers\",\"set-header\",\"set-status\",\"timeout\",\"trace\",\"util\",\"validate-request\"],\"kind\":\"dev\",\"name\":\"tower-http\",\"req\":\"^0.6.0\"},{\"name\":\"tower-layer\",\"req\":\"^0.3.2\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"json\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"features\":[\"serde\",\"v4\"],\"kind\":\"dev\",\"name\":\"uuid\",\"req\":\"^1.0\"}],\"features\":{\"__private\":[\"tokio\",\"http1\",\"dep:reqwest\"],\"__private_docs\":[\"axum-core/__private_docs\",\"tower/full\",\"dep:serde\",\"dep:tower-http\"],\"default\":[\"form\",\"http1\",\"json\",\"matched-path\",\"original-uri\",\"query\",\"tokio\",\"tower-log\",\"tracing\"],\"form\":[\"dep:form_urlencoded\",\"dep:serde_urlencoded\",\"dep:serde_path_to_error\"],\"http1\":[\"dep:hyper\",\"hyper?/http1\",\"hyper-util?/http1\"],\"http2\":[\"dep:hyper\",\"hyper?/http2\",\"hyper-util?/http2\"],\"json\":[\"dep:serde_json\",\"dep:serde_path_to_error\"],\"macros\":[\"dep:axum-macros\"],\"matched-path\":[],\"multipart\":[\"dep:multer\"],\"original-uri\":[],\"query\":[\"dep:form_urlencoded\",\"dep:serde_urlencoded\",\"dep:serde_path_to_error\"],\"tokio\":[\"dep:hyper-util\",\"dep:tokio\",\"tokio/net\",\"tokio/rt\",\"tower/make\",\"tokio/macros\"],\"tower-log\":[\"tower/log\"],\"tracing\":[\"dep:tracing\",\"axum-core/tracing\"],\"ws\":[\"dep:hyper\",\"tokio\",\"dep:tokio-tungstenite\",\"dep:sha1\",\"dep:base64\"]}}", "backtrace_0.3.76": "{\"dependencies\":[{\"default_features\":false,\"name\":\"addr2line\",\"req\":\"^0.25.0\",\"target\":\"cfg(not(all(windows, target_env = \\\"msvc\\\", not(target_vendor = \\\"uwp\\\"))))\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"cpp_demangle\",\"optional\":true,\"req\":\"^0.5.0\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.156\",\"target\":\"cfg(not(all(windows, target_env = \\\"msvc\\\", not(target_vendor = \\\"uwp\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libloading\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"miniz_oxide\",\"req\":\"^0.8\",\"target\":\"cfg(not(all(windows, target_env = \\\"msvc\\\", not(target_vendor = \\\"uwp\\\"))))\"},{\"default_features\":false,\"features\":[\"read_core\",\"elf\",\"macho\",\"pe\",\"xcoff\",\"unaligned\",\"archive\"],\"name\":\"object\",\"req\":\"^0.37.0\",\"target\":\"cfg(not(all(windows, target_env = \\\"msvc\\\", not(target_vendor = \\\"uwp\\\"))))\"},{\"name\":\"rustc-demangle\",\"req\":\"^0.1.24\"},{\"default_features\":false,\"name\":\"ruzstd\",\"optional\":true,\"req\":\"^0.8.1\",\"target\":\"cfg(not(all(windows, target_env = \\\"msvc\\\", not(target_vendor = \\\"uwp\\\"))))\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"windows-link\",\"req\":\"^0.2\",\"target\":\"cfg(any(windows, target_os = \\\"cygwin\\\"))\"}],\"features\":{\"coresymbolication\":[],\"dbghelp\":[],\"default\":[\"std\"],\"dl_iterate_phdr\":[],\"dladdr\":[],\"kernel32\":[],\"libunwind\":[],\"ruzstd\":[\"dep:ruzstd\"],\"serialize-serde\":[\"serde\"],\"std\":[],\"unix-backtrace\":[]}}", "base16ct_0.2.0": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"std\":[\"alloc\"]}}", + "base64-simd_0.8.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.20.0\"},{\"kind\":\"dev\",\"name\":\"const-str\",\"req\":\"^0.5.3\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.8\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"outref\",\"req\":\"^0.5.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"name\":\"vsimd\",\"req\":\"^0.8.0\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.33\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"}],\"features\":{\"alloc\":[\"vsimd/alloc\"],\"default\":[\"std\",\"detect\"],\"detect\":[\"vsimd/detect\"],\"std\":[\"alloc\",\"vsimd/std\"],\"unstable\":[\"vsimd/unstable\"]}}", "base64_0.21.7": "{\"dependencies\":[{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^3.2.25\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.13.0\"},{\"kind\":\"dev\",\"name\":\"rstest_reuse\",\"req\":\"^0.6.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.25\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", "base64_0.22.1": "{\"dependencies\":[{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^3.2.25\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.13.0\"},{\"kind\":\"dev\",\"name\":\"rstest_reuse\",\"req\":\"^0.6.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.25\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", "base64ct_1.8.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.6\"}],\"features\":{\"alloc\":[],\"std\":[\"alloc\"]}}", @@ -681,6 +701,7 @@ "bitflags_1.3.2": "{\"dependencies\":[{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.2\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3\"}],\"features\":{\"default\":[],\"example_generated\":[],\"rustc-dep-of-std\":[\"core\",\"compiler_builtins\"]}}", "bitflags_2.10.0": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"arbitrary\",\"req\":\"^1.0\"},{\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1.12\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.12.2\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.228\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde_lib\",\"package\":\"serde\",\"req\":\"^1.0.103\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.19\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.18\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"zerocopy\",\"req\":\"^0.8\"}],\"features\":{\"example_generated\":[],\"serde\":[\"serde_core\"],\"std\":[]}}", "bitflags_2.11.0": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"arbitrary\",\"req\":\"^1.0\"},{\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1.12\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.12.2\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.228\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde_lib\",\"package\":\"serde\",\"req\":\"^1.0.103\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.19\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.18\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"zerocopy\",\"req\":\"^0.8\"}],\"features\":{\"example_generated\":[],\"serde\":[\"serde_core\"],\"std\":[]}}", + "blake2_0.10.6": "{\"dependencies\":[{\"features\":[\"mac\"],\"name\":\"digest\",\"req\":\"^0.10.3\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10.3\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"}],\"features\":{\"default\":[\"std\"],\"reset\":[],\"simd\":[],\"simd_asm\":[\"simd_opt\"],\"simd_opt\":[\"simd\"],\"size_opt\":[],\"std\":[\"digest/std\"]}}", "block-buffer_0.10.4": "{\"dependencies\":[{\"name\":\"generic-array\",\"req\":\"^0.14\"}],\"features\":{}}", "block-padding_0.3.3": "{\"dependencies\":[{\"name\":\"generic-array\",\"req\":\"^0.14\"}],\"features\":{\"std\":[]}}", "block2_0.6.2": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"objc2\",\"req\":\">=0.6.2, <0.8.0\"}],\"features\":{\"alloc\":[],\"compiler-rt\":[\"objc2/unstable-compiler-rt\"],\"default\":[\"std\"],\"gnustep-1-7\":[\"objc2/gnustep-1-7\"],\"gnustep-1-8\":[\"gnustep-1-7\",\"objc2/gnustep-1-8\"],\"gnustep-1-9\":[\"gnustep-1-8\",\"objc2/gnustep-1-9\"],\"gnustep-2-0\":[\"gnustep-1-9\",\"objc2/gnustep-2-0\"],\"gnustep-2-1\":[\"gnustep-2-0\",\"objc2/gnustep-2-1\"],\"std\":[\"alloc\"],\"unstable-coerce-pointee\":[],\"unstable-objfw\":[],\"unstable-private\":[],\"unstable-winobjc\":[\"gnustep-1-8\"]}}", @@ -692,6 +713,7 @@ "bytemuck_1.25.0": "{\"dependencies\":[{\"name\":\"bytemuck_derive\",\"optional\":true,\"req\":\"^1.10.2\"},{\"name\":\"rustversion\",\"optional\":true,\"req\":\"^1.0.22\"}],\"features\":{\"aarch64_simd\":[],\"align_offset\":[],\"alloc_uninit\":[],\"avx512_simd\":[],\"const_zeroed\":[],\"derive\":[\"bytemuck_derive\"],\"extern_crate_alloc\":[],\"extern_crate_std\":[\"extern_crate_alloc\"],\"impl_core_error\":[],\"latest_stable_rust\":[\"aarch64_simd\",\"avx512_simd\",\"align_offset\",\"alloc_uninit\",\"const_zeroed\",\"derive\",\"impl_core_error\",\"min_const_generics\",\"must_cast\",\"must_cast_extra\",\"pod_saturating\",\"track_caller\",\"transparentwrapper_extra\",\"wasm_simd\",\"zeroable_atomics\",\"zeroable_maybe_uninit\",\"zeroable_unwind_fn\"],\"min_const_generics\":[],\"must_cast\":[],\"must_cast_extra\":[\"must_cast\"],\"nightly_docs\":[],\"nightly_float\":[],\"nightly_portable_simd\":[\"rustversion\"],\"nightly_stdsimd\":[],\"pod_saturating\":[],\"track_caller\":[],\"transparentwrapper_extra\":[],\"unsound_ptr_pod_impl\":[],\"wasm_simd\":[],\"zeroable_atomics\":[],\"zeroable_maybe_uninit\":[],\"zeroable_unwind_fn\":[]}}", "byteorder-lite_0.1.0": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "byteorder_1.5.0": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"std\"],\"i128\":[],\"std\":[]}}", + "bytes-utils_0.1.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"either\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.144\"}],\"features\":{\"default\":[\"std\"],\"serde\":[\"dep:serde\",\"bytes/serde\"],\"std\":[\"bytes/default\"]}}", "bytes_1.11.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"require-cas\"],\"name\":\"extra-platforms\",\"optional\":true,\"package\":\"portable-atomic\",\"req\":\"^1.3\"},{\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.60\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "bytestring_1.5.0": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"bytes\",\"req\":\"^1.2\"},{\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"}],\"features\":{\"serde\":[\"dep:serde_core\"]}}", "bzip2-sys_0.1.13+1.0.8": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"}],\"features\":{\"__disabled\":[],\"static\":[]}}", @@ -778,6 +800,8 @@ "crunchy_0.2.4": "{\"dependencies\":[],\"features\":{\"default\":[\"limit_128\"],\"limit_1024\":[],\"limit_128\":[],\"limit_2048\":[],\"limit_256\":[],\"limit_512\":[],\"limit_64\":[],\"std\":[]}}", "crypto-bigint_0.5.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"der\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"generic-array\",\"optional\":true,\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-integer\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand_chacha\",\"req\":\"^0.3\"},{\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6.4\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"rlp\",\"optional\":true,\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"serdect\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.4\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"serdect?/alloc\"],\"default\":[\"rand\"],\"extra-sizes\":[],\"rand\":[\"rand_core/std\"],\"serde\":[\"dep:serdect\"]}}", "crypto-common_0.1.7": "{\"dependencies\":[{\"features\":[\"more_lengths\"],\"name\":\"generic-array\",\"req\":\"=0.14.7\"},{\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"typenum\",\"req\":\"^1.14\"}],\"features\":{\"getrandom\":[\"rand_core/getrandom\"],\"std\":[]}}", + "crypto_box_0.9.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aead\",\"req\":\"^0.5.2\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"blake2\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"chacha20\",\"optional\":true,\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"crypto_secretbox\",\"req\":\"^0.1.1\"},{\"default_features\":false,\"features\":[\"zeroize\"],\"name\":\"curve25519-dalek\",\"req\":\"^4\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rmp-serde\",\"req\":\"^1\"},{\"name\":\"salsa20\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"serdect\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"zeroize\",\"req\":\"^1\"}],\"features\":{\"alloc\":[\"aead/alloc\"],\"chacha20\":[\"dep:chacha20\",\"crypto_secretbox/chacha20\"],\"default\":[\"alloc\",\"getrandom\",\"salsa20\"],\"getrandom\":[\"aead/getrandom\",\"rand_core\"],\"heapless\":[\"aead/heapless\"],\"rand_core\":[\"aead/rand_core\"],\"salsa20\":[\"dep:salsa20\",\"crypto_secretbox/salsa20\"],\"seal\":[\"dep:blake2\",\"alloc\"],\"serde\":[\"dep:serdect\"],\"std\":[\"aead/std\"]}}", + "crypto_secretbox_0.1.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aead\",\"req\":\"^0.5\"},{\"features\":[\"zeroize\"],\"name\":\"chacha20\",\"optional\":true,\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"cipher\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"zeroize\"],\"name\":\"generic-array\",\"req\":\"^0.14.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"name\":\"poly1305\",\"req\":\"^0.8\"},{\"features\":[\"zeroize\"],\"name\":\"salsa20\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"zeroize\",\"req\":\"^1\"}],\"features\":{\"alloc\":[\"aead/alloc\"],\"default\":[\"alloc\",\"getrandom\",\"salsa20\"],\"getrandom\":[\"aead/getrandom\",\"rand_core\"],\"heapless\":[\"aead/heapless\"],\"rand_core\":[\"aead/rand_core\"],\"std\":[\"aead/std\",\"alloc\"],\"stream\":[\"aead/stream\"]}}", "csv-core_0.1.13": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"arrayvec\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2\"}],\"features\":{\"default\":[],\"libc\":[\"memchr/libc\"]}}", "csv_1.4.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\",\"serde\"],\"kind\":\"dev\",\"name\":\"bstr\",\"req\":\"^1.7.0\"},{\"name\":\"csv-core\",\"req\":\"^0.1.11\"},{\"name\":\"itoa\",\"req\":\"^1\"},{\"name\":\"ryu\",\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.221\"},{\"name\":\"serde_core\",\"req\":\"^1.0.221\"}],\"features\":{}}", "ctor-proc-macro_0.0.7": "{\"dependencies\":[],\"features\":{\"default\":[]}}", @@ -849,6 +873,8 @@ "dylint_testing_5.0.0": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"cargo_metadata\",\"req\":\"^0.23\"},{\"name\":\"compiletest_rs\",\"req\":\"^0.11\"},{\"name\":\"dylint\",\"req\":\"=5.0.0\"},{\"name\":\"dylint_internal\",\"req\":\"=5.0.0\"},{\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"once_cell\",\"req\":\"^1.21\"},{\"name\":\"regex\",\"req\":\"^1.11\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"tempfile\",\"req\":\"^3.23\"}],\"features\":{\"default\":[],\"deny_warnings\":[]}}", "dyn-clone_1.0.20": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.66\"}],\"features\":{}}", "ecdsa_0.16.9": "{\"dependencies\":[{\"name\":\"der\",\"optional\":true,\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"oid\"],\"name\":\"digest\",\"optional\":true,\"req\":\"^0.10.7\"},{\"default_features\":false,\"features\":[\"digest\",\"sec1\"],\"name\":\"elliptic-curve\",\"req\":\"^0.13.6\"},{\"default_features\":false,\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"elliptic-curve\",\"req\":\"^0.13\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"name\":\"rfc6979\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serdect\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"oid\"],\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"default_features\":false,\"features\":[\"rand_core\"],\"name\":\"signature\",\"req\":\"^2.0, <2.3\"},{\"default_features\":false,\"name\":\"spki\",\"optional\":true,\"req\":\"^0.7.2\"}],\"features\":{\"alloc\":[\"elliptic-curve/alloc\",\"signature/alloc\",\"spki/alloc\"],\"arithmetic\":[\"elliptic-curve/arithmetic\"],\"default\":[\"digest\"],\"dev\":[\"arithmetic\",\"digest\",\"elliptic-curve/dev\",\"hazmat\"],\"digest\":[\"dep:digest\",\"signature/digest\"],\"hazmat\":[],\"pem\":[\"elliptic-curve/pem\",\"pkcs8\"],\"pkcs8\":[\"digest\",\"elliptic-curve/pkcs8\",\"der\"],\"serde\":[\"elliptic-curve/serde\",\"serdect\"],\"signing\":[\"arithmetic\",\"digest\",\"hazmat\",\"rfc6979\"],\"std\":[\"alloc\",\"elliptic-curve/std\",\"signature/std\"],\"verifying\":[\"arithmetic\",\"digest\",\"hazmat\"]}}", + "ed25519-dalek_2.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"blake2\",\"req\":\"^0.10\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"features\":[\"digest\"],\"name\":\"curve25519-dalek\",\"req\":\"^4\"},{\"default_features\":false,\"features\":[\"digest\",\"rand_core\"],\"kind\":\"dev\",\"name\":\"curve25519-dalek\",\"req\":\"^4\"},{\"default_features\":false,\"name\":\"ed25519\",\"req\":\">=2.2, <2.3\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"merlin\",\"optional\":true,\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6.4\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6.4\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"sha2\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"sha3\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"signature\",\"optional\":true,\"req\":\">=2.0, <2.3\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.3.0\"},{\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"static_secrets\"],\"kind\":\"dev\",\"name\":\"x25519-dalek\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.5\"}],\"features\":{\"alloc\":[\"curve25519-dalek/alloc\",\"ed25519/alloc\",\"serde?/alloc\",\"zeroize/alloc\"],\"asm\":[\"sha2/asm\"],\"batch\":[\"alloc\",\"merlin\",\"rand_core\"],\"default\":[\"fast\",\"std\",\"zeroize\"],\"digest\":[\"signature/digest\"],\"fast\":[\"curve25519-dalek/precomputed-tables\"],\"hazmat\":[],\"legacy_compatibility\":[\"curve25519-dalek/legacy_compatibility\"],\"pem\":[\"alloc\",\"ed25519/pem\",\"pkcs8\"],\"pkcs8\":[\"ed25519/pkcs8\"],\"rand_core\":[\"dep:rand_core\"],\"serde\":[\"dep:serde\",\"ed25519/serde\"],\"std\":[\"alloc\",\"ed25519/std\",\"serde?/std\",\"sha2/std\"],\"zeroize\":[\"dep:zeroize\",\"curve25519-dalek/zeroize\"]}}", + "ed25519_2.2.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"features\":[\"rand_core\"],\"kind\":\"dev\",\"name\":\"ed25519-dalek\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"name\":\"pkcs8\",\"optional\":true,\"req\":\"^0.10\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"default_features\":false,\"features\":[\"signature\"],\"kind\":\"dev\",\"name\":\"ring-compat\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"serde_bytes\",\"optional\":true,\"req\":\"^0.11\"},{\"default_features\":false,\"name\":\"signature\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"pkcs8?/alloc\"],\"default\":[\"std\"],\"pem\":[\"alloc\",\"pkcs8/pem\"],\"serde_bytes\":[\"serde\",\"dep:serde_bytes\"],\"std\":[\"pkcs8?/std\",\"signature/std\"]}}", "either_1.15.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\",\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.95\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[],\"use_std\":[\"std\"]}}", "elliptic-curve_0.13.8": "{\"dependencies\":[{\"name\":\"base16ct\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"base64ct\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"rand_core\",\"generic-array\",\"zeroize\"],\"name\":\"crypto-bigint\",\"req\":\"^0.5\"},{\"name\":\"digest\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"ff\",\"optional\":true,\"req\":\"^0.13\"},{\"default_features\":false,\"features\":[\"zeroize\"],\"name\":\"generic-array\",\"req\":\"^0.14.6\"},{\"default_features\":false,\"name\":\"group\",\"optional\":true,\"req\":\"^0.13\"},{\"name\":\"hex-literal\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"hkdf\",\"optional\":true,\"req\":\"^0.12.1\"},{\"features\":[\"alloc\"],\"name\":\"pem-rfc7468\",\"optional\":true,\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"pkcs8\",\"optional\":true,\"req\":\"^0.10.2\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.6.4\"},{\"features\":[\"subtle\",\"zeroize\"],\"name\":\"sec1\",\"optional\":true,\"req\":\"^0.7.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.47\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serdect\",\"optional\":true,\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"sha3\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"tap\",\"optional\":true,\"req\":\"^1.0.1\"},{\"default_features\":false,\"name\":\"zeroize\",\"req\":\"^1.7\"}],\"features\":{\"alloc\":[\"base16ct/alloc\",\"ff?/alloc\",\"group?/alloc\",\"pkcs8?/alloc\",\"sec1?/alloc\",\"zeroize/alloc\"],\"arithmetic\":[\"group\"],\"bits\":[\"arithmetic\",\"ff/bits\",\"dep:tap\"],\"default\":[\"arithmetic\"],\"dev\":[\"arithmetic\",\"dep:hex-literal\",\"pem\",\"pkcs8\"],\"ecdh\":[\"arithmetic\",\"digest\",\"dep:hkdf\"],\"group\":[\"dep:group\",\"ff\"],\"hash2curve\":[\"arithmetic\",\"digest\"],\"hazmat\":[],\"jwk\":[\"dep:base64ct\",\"dep:serde_json\",\"alloc\",\"serde\",\"zeroize/alloc\"],\"pem\":[\"dep:pem-rfc7468\",\"alloc\",\"arithmetic\",\"pkcs8\",\"sec1/pem\"],\"pkcs8\":[\"dep:pkcs8\",\"sec1\"],\"serde\":[\"dep:serdect\",\"alloc\",\"pkcs8\",\"sec1/serde\"],\"std\":[\"alloc\",\"rand_core/std\",\"pkcs8?/std\",\"sec1?/std\"],\"voprf\":[\"digest\"]}}", "ena_0.14.3": "{\"dependencies\":[{\"name\":\"dogged\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"log\",\"req\":\"^0.4\"}],\"features\":{\"bench\":[],\"persistent\":[\"dogged\"]}}", @@ -1035,6 +1061,7 @@ "home_0.5.9": "{\"dependencies\":[{\"features\":[\"Win32_Foundation\",\"Win32_UI_Shell\",\"Win32_System_Com\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "hostname_0.4.2": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(any(unix, target_os = \\\"redox\\\"))\"},{\"kind\":\"dev\",\"name\":\"similar-asserts\",\"req\":\"^1.6.1\"},{\"kind\":\"dev\",\"name\":\"version-sync\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"windows-bindgen\",\"req\":\"^0.65\"},{\"name\":\"windows-link\",\"req\":\"^0.2\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"default\":[],\"set\":[]}}", "http-body-util_0.1.3": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1\"},{\"name\":\"http-body\",\"req\":\"^1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"macros\",\"rt\",\"sync\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"}],\"features\":{\"channel\":[\"dep:tokio\"],\"default\":[],\"full\":[\"channel\"]}}", + "http-body_0.4.6": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"http\",\"req\":\"^0.2\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"}],\"features\":{}}", "http-body_1.0.1": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"http\",\"req\":\"^1\"}],\"features\":{}}", "http-range-header_0.4.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.8.3\"}],\"features\":{}}", "http_0.2.12": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"fnv\",\"req\":\"^1.0.5\"},{\"kind\":\"dev\",\"name\":\"indexmap\",\"req\":\"<=1.8\"},{\"name\":\"itoa\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7.0\"},{\"kind\":\"dev\",\"name\":\"seahash\",\"req\":\"^3.0.5\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{}}", @@ -1253,6 +1280,7 @@ "ordered-stream_0.2.0": "{\"dependencies\":[{\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"futures-executor\",\"req\":\"^0.3.25\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.25\"}],\"features\":{}}", "os_info_3.14.0": "{\"dependencies\":[{\"name\":\"android_system_properties\",\"req\":\"^0.1\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"features\":[\"feature\"],\"name\":\"nix\",\"req\":\"^0.30\",\"target\":\"cfg(any(target_os = \\\"aix\\\", target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"illumos\\\", target_os = \\\"linux\\\", target_os = \\\"macos\\\", target_os = \\\"netbsd\\\", target_os = \\\"openbsd\\\", target_os = \\\"cygwin\\\"))\"},{\"name\":\"objc2\",\"req\":\"^0.6\",\"target\":\"cfg(target_os = \\\"ios\\\")\"},{\"features\":[\"NSString\"],\"name\":\"objc2-foundation\",\"req\":\"^0.3\",\"target\":\"cfg(target_os = \\\"ios\\\")\"},{\"features\":[\"NSData\",\"NSError\",\"NSEnumerator\",\"NSString\"],\"name\":\"objc2-foundation\",\"req\":\"^0.3\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"name\":\"objc2-ui-kit\",\"req\":\"^0.3\",\"target\":\"cfg(target_os = \\\"ios\\\")\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1\"},{\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0.3\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_LibraryLoader\",\"Win32_System_Registry\",\"Win32_System_SystemInformation\",\"Win32_System_SystemServices\",\"Win32_System_Threading\",\"Win32_UI_WindowsAndMessaging\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"serde\"]}}", "os_pipe_1.2.3": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.62\",\"target\":\"cfg(not(windows))\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Pipes\",\"Win32_Security\"],\"name\":\"windows-sys\",\"req\":\">=0.28, <=0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"io_safety\":[]}}", + "outref_0.5.2": "{\"dependencies\":[],\"features\":{}}", "owo-colors_4.3.0": "{\"dependencies\":[{\"name\":\"supports-color\",\"optional\":true,\"req\":\"^3.0.0\"},{\"name\":\"supports-color-2\",\"optional\":true,\"package\":\"supports-color\",\"req\":\"^2.0\"}],\"features\":{\"alloc\":[],\"supports-colors\":[\"dep:supports-color-2\",\"supports-color\"]}}", "p256_0.13.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"blobby\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"der\"],\"name\":\"ecdsa-core\",\"optional\":true,\"package\":\"ecdsa\",\"req\":\"^0.16\"},{\"default_features\":false,\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"ecdsa-core\",\"package\":\"ecdsa\",\"req\":\"^0.16\"},{\"default_features\":false,\"features\":[\"hazmat\",\"sec1\"],\"name\":\"elliptic-curve\",\"req\":\"^0.13.1\"},{\"name\":\"hex-literal\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"name\":\"primeorder\",\"optional\":true,\"req\":\"^0.13\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"primeorder\",\"req\":\"^0.13\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"features\":[\"getrandom\"],\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"serdect\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10\"}],\"features\":{\"alloc\":[\"ecdsa-core?/alloc\",\"elliptic-curve/alloc\"],\"arithmetic\":[\"dep:primeorder\",\"elliptic-curve/arithmetic\"],\"bits\":[\"arithmetic\",\"elliptic-curve/bits\"],\"default\":[\"arithmetic\",\"ecdsa\",\"pem\",\"std\"],\"digest\":[\"ecdsa-core/digest\",\"ecdsa-core/hazmat\"],\"ecdh\":[\"arithmetic\",\"elliptic-curve/ecdh\"],\"ecdsa\":[\"arithmetic\",\"ecdsa-core/signing\",\"ecdsa-core/verifying\",\"sha256\"],\"expose-field\":[\"arithmetic\"],\"hash2curve\":[\"arithmetic\",\"elliptic-curve/hash2curve\"],\"jwk\":[\"elliptic-curve/jwk\"],\"pem\":[\"elliptic-curve/pem\",\"ecdsa-core/pem\",\"pkcs8\"],\"pkcs8\":[\"ecdsa-core?/pkcs8\",\"elliptic-curve/pkcs8\"],\"serde\":[\"ecdsa-core?/serde\",\"elliptic-curve/serde\",\"primeorder?/serde\",\"serdect\"],\"sha256\":[\"digest\",\"sha2\"],\"std\":[\"alloc\",\"ecdsa-core?/std\",\"elliptic-curve/std\"],\"test-vectors\":[\"dep:hex-literal\"],\"voprf\":[\"elliptic-curve/voprf\",\"sha2\"]}}", "parking_2.2.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3.0.0\"},{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7\",\"target\":\"cfg(loom)\"}],\"features\":{}}", @@ -1408,7 +1436,7 @@ "rustix_1.1.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(criterion, not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.182\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.182\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.171\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"name\":\"libc_errno\",\"optional\":true,\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\"},{\"default_features\":false,\"features\":[\"general\",\"ioctl\",\"no_std\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.12\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"features\":[\"auxvec\",\"general\",\"errno\",\"ioctl\",\"no_std\",\"elf\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.12\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.20.3\",\"target\":\"cfg(windows)\"},{\"name\":\"rustc-std-workspace-alloc\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"all-apis\":[\"event\",\"fs\",\"io_uring\",\"mm\",\"mount\",\"net\",\"param\",\"pipe\",\"process\",\"pty\",\"rand\",\"runtime\",\"shm\",\"stdio\",\"system\",\"termios\",\"thread\",\"time\"],\"alloc\":[],\"default\":[\"std\"],\"event\":[],\"fs\":[],\"io_uring\":[\"event\",\"fs\",\"net\",\"thread\",\"linux-raw-sys/io_uring\"],\"linux_4_11\":[],\"linux_5_1\":[\"linux_4_11\"],\"linux_5_11\":[\"linux_5_1\"],\"linux_latest\":[\"linux_5_11\"],\"mm\":[],\"mount\":[],\"net\":[\"linux-raw-sys/net\",\"linux-raw-sys/netlink\",\"linux-raw-sys/if_ether\",\"linux-raw-sys/xdp\"],\"param\":[],\"pipe\":[],\"process\":[\"linux-raw-sys/prctl\"],\"pty\":[\"fs\"],\"rand\":[],\"runtime\":[\"linux-raw-sys/prctl\"],\"rustc-dep-of-std\":[\"core\",\"rustc-std-workspace-alloc\",\"linux-raw-sys/rustc-dep-of-std\",\"bitflags/rustc-dep-of-std\"],\"shm\":[\"fs\"],\"std\":[\"bitflags/std\",\"alloc\",\"libc?/std\",\"libc_errno?/std\"],\"stdio\":[],\"system\":[\"linux-raw-sys/system\"],\"termios\":[],\"thread\":[\"linux-raw-sys/prctl\"],\"time\":[],\"try_close\":[],\"use-explicitly-provided-auxv\":[],\"use-libc\":[\"libc_errno\",\"libc\"],\"use-libc-auxv\":[]}}", "rustls-native-certs_0.8.3": "{\"dependencies\":[{\"name\":\"openssl-probe\",\"req\":\"^0.2\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"features\":[\"std\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.10\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"kind\":\"dev\",\"name\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"name\":\"schannel\",\"req\":\"^0.1\",\"target\":\"cfg(windows)\"},{\"name\":\"security-framework\",\"req\":\"^3\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5\"},{\"kind\":\"dev\",\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18\"}],\"features\":{}}", "rustls-pki-types_1.14.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"crabgrind\",\"req\":\"=0.1.9\",\"target\":\"cfg(all(target_os = \\\"linux\\\", target_arch = \\\"x86_64\\\"))\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"dep:zeroize\"],\"default\":[\"alloc\"],\"std\":[\"alloc\"],\"web\":[\"web-time\"]}}", - "rustls-webpki_0.103.12": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"kind\":\"dev\",\"name\":\"bzip2\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.17.2\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14.2\"},{\"default_features\":false,\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18.1\"}],\"features\":{\"alloc\":[\"ring?/alloc\",\"pki-types/alloc\"],\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"aws-lc-rs-fips\":[\"dep:aws-lc-rs\",\"aws-lc-rs/fips\"],\"aws-lc-rs-unstable\":[\"aws-lc-rs\",\"aws-lc-rs/unstable\"],\"default\":[\"std\"],\"ring\":[\"dep:ring\"],\"std\":[\"alloc\",\"pki-types/std\"]}}", + "rustls-webpki_0.103.13": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"kind\":\"dev\",\"name\":\"bzip2\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.17.2\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14.2\"},{\"default_features\":false,\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18.1\"}],\"features\":{\"alloc\":[\"ring?/alloc\",\"pki-types/alloc\"],\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"aws-lc-rs-fips\":[\"dep:aws-lc-rs\",\"aws-lc-rs/fips\"],\"aws-lc-rs-unstable\":[\"aws-lc-rs\",\"aws-lc-rs/unstable\"],\"default\":[\"std\"],\"ring\":[\"dep:ring\"],\"std\":[\"alloc\",\"pki-types/std\"]}}", "rustls_0.23.36": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"brotli\",\"optional\":true,\"req\":\"^8\"},{\"name\":\"brotli-decompressor\",\"optional\":true,\"req\":\"^5.0.0\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"default-hasher\",\"inline-more\"],\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"macro_rules_attribute\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4.4\"},{\"default_features\":false,\"features\":[\"alloc\",\"race\"],\"name\":\"once_cell\",\"req\":\"^1.16\"},{\"features\":[\"alloc\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"pem\",\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"kind\":\"build\",\"name\":\"rustversion\",\"optional\":true,\"req\":\"^1.0.6\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.6\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103.5\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17\"},{\"name\":\"zeroize\",\"req\":\"^1.8\"},{\"name\":\"zlib-rs\",\"optional\":true,\"req\":\"^0.5\"}],\"features\":{\"aws-lc-rs\":[\"aws_lc_rs\"],\"aws_lc_rs\":[\"dep:aws-lc-rs\",\"webpki/aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"brotli\":[\"dep:brotli\",\"dep:brotli-decompressor\",\"std\"],\"custom-provider\":[],\"default\":[\"aws_lc_rs\",\"logging\",\"prefer-post-quantum\",\"std\",\"tls12\"],\"fips\":[\"aws_lc_rs\",\"aws-lc-rs?/fips\",\"webpki/aws-lc-rs-fips\"],\"logging\":[\"log\"],\"prefer-post-quantum\":[\"aws_lc_rs\"],\"read_buf\":[\"rustversion\",\"std\"],\"ring\":[\"dep:ring\",\"webpki/ring\"],\"std\":[\"webpki/std\",\"pki-types/std\",\"once_cell/std\"],\"tls12\":[],\"zlib\":[\"dep:zlib-rs\"]}}", "rustversion_1.0.22": "{\"dependencies\":[{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.49\"}],\"features\":{}}", "rustyline_14.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.2\"},{\"name\":\"bitflags\",\"req\":\"^2.0\"},{\"default_features\":false,\"name\":\"buffer-redux\",\"optional\":true,\"req\":\"^1.0\",\"target\":\"cfg(unix)\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"clipboard-win\",\"req\":\"^5.0\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"fd-lock\",\"optional\":true,\"req\":\"^4.0.0\"},{\"name\":\"home\",\"optional\":true,\"req\":\"^0.5.4\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"memchr\",\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"fs\",\"ioctl\",\"poll\",\"signal\",\"term\"],\"name\":\"nix\",\"req\":\"^0.28\",\"target\":\"cfg(unix)\"},{\"name\":\"radix_trie\",\"optional\":true,\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.5.5\"},{\"default_features\":false,\"features\":[\"bundled\",\"backup\"],\"name\":\"rusqlite\",\"optional\":true,\"req\":\"^0.31.0\"},{\"name\":\"rustyline-derive\",\"optional\":true,\"req\":\"^0.10.0\"},{\"default_features\":false,\"name\":\"signal-hook\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(unix)\"},{\"default_features\":false,\"name\":\"skim\",\"optional\":true,\"req\":\"^0.10\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"name\":\"termios\",\"optional\":true,\"req\":\"^0.3.3\",\"target\":\"cfg(unix)\"},{\"name\":\"unicode-segmentation\",\"req\":\"^1.0\"},{\"name\":\"unicode-width\",\"req\":\"^0.1\"},{\"name\":\"utf8parse\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Console\",\"Win32_Security\",\"Win32_System_Threading\",\"Win32_UI_Input_KeyboardAndMouse\"],\"name\":\"windows-sys\",\"req\":\"^0.52.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"case_insensitive_history_search\":[\"regex\"],\"custom-bindings\":[\"radix_trie\"],\"default\":[\"custom-bindings\",\"with-dirs\",\"with-file-history\"],\"derive\":[\"rustyline-derive\"],\"with-dirs\":[\"home\"],\"with-file-history\":[\"fd-lock\"],\"with-fuzzy\":[\"skim\"],\"with-sqlite-history\":[\"rusqlite\"]}}", @@ -1652,6 +1680,7 @@ "vcpkg_0.2.15": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempdir\",\"req\":\"^0.3.7\"}],\"features\":{}}", "version-compare_0.2.1": "{\"dependencies\":[],\"features\":{}}", "version_check_0.9.5": "{\"dependencies\":[],\"features\":{}}", + "vsimd_0.8.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"const-str\",\"req\":\"^0.5.3\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.8\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.33\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"}],\"features\":{\"alloc\":[],\"detect\":[\"std\"],\"std\":[\"alloc\"],\"unstable\":[]}}", "vt100_0.16.2": "{\"dependencies\":[{\"name\":\"itoa\",\"req\":\"^1.0.15\"},{\"features\":[\"term\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.30.1\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.219\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.140\"},{\"kind\":\"dev\",\"name\":\"terminal_size\",\"req\":\"^0.4.2\"},{\"name\":\"unicode-width\",\"req\":\"^0.2.1\"},{\"name\":\"vte\",\"req\":\"^0.15.0\"}],\"features\":{}}", "vte_0.15.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arrayvec\",\"req\":\"^0.7.2\"},{\"default_features\":false,\"name\":\"bitflags\",\"optional\":true,\"req\":\"^2.3.3\"},{\"default_features\":false,\"name\":\"cursor-icon\",\"optional\":true,\"req\":\"^1.0.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.7.4\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.160\"}],\"features\":{\"ansi\":[\"log\",\"cursor-icon\",\"bitflags\"],\"default\":[\"std\"],\"serde\":[\"dep:serde\"],\"std\":[\"memchr/std\"]}}", "wait-timeout_0.2.1": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.56\",\"target\":\"cfg(unix)\"}],\"features\":{}}", @@ -1776,6 +1805,7 @@ "x509-parser_0.18.1": "{\"dependencies\":[{\"features\":[\"datetime\"],\"name\":\"asn1-rs\",\"req\":\"^0.7.0\"},{\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"data-encoding\",\"req\":\"^2.2.1\"},{\"features\":[\"bigint\"],\"name\":\"der-parser\",\"req\":\"^10.0\"},{\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"name\":\"nom\",\"req\":\"^7.0\"},{\"features\":[\"crypto\",\"x509\",\"x962\"],\"name\":\"oid-registry\",\"req\":\"^0.8.1\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17.12\"},{\"name\":\"rusticata-macros\",\"req\":\"^4.0\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"formatting\"],\"name\":\"time\",\"req\":\"^0.3.35\"}],\"features\":{\"default\":[],\"validate\":[],\"verify\":[\"ring\"],\"verify-aws\":[\"aws-lc-rs\"]}}", "xattr_1.6.1": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.150\",\"target\":\"cfg(any(target_os = \\\"freebsd\\\", target_os = \\\"netbsd\\\"))\"},{\"default_features\":false,\"features\":[\"fs\",\"std\"],\"name\":\"rustix\",\"req\":\"^1.0.0\",\"target\":\"cfg(any(target_os = \\\"android\\\", target_os = \\\"linux\\\", target_os = \\\"macos\\\", target_os = \\\"hurd\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"default\":[\"unsupported\"],\"unsupported\":[]}}", "xdg-home_1.3.0": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"Win32_Foundation\",\"Win32_UI_Shell\",\"Win32_System_Com\"],\"name\":\"windows-sys\",\"req\":\"^0.59\",\"target\":\"cfg(windows)\"}],\"features\":{}}", + "xmlparser_0.13.6": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "xz2_0.1.7": "{\"dependencies\":[{\"name\":\"futures\",\"optional\":true,\"req\":\"^0.1.26\"},{\"name\":\"lzma-sys\",\"req\":\"^0.1.18\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.0\"},{\"kind\":\"dev\",\"name\":\"tokio-core\",\"req\":\"^0.1.17\"},{\"name\":\"tokio-io\",\"optional\":true,\"req\":\"^0.1.12\"}],\"features\":{\"static\":[\"lzma-sys/static\"],\"tokio\":[\"tokio-io\",\"futures\"]}}", "yaml-rust_0.4.5": "{\"dependencies\":[{\"name\":\"linked-hash-map\",\"req\":\"^0.5.3\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9\"}],\"features\":{}}", "yansi_1.0.1": "{\"dependencies\":[{\"name\":\"is-terminal\",\"optional\":true,\"req\":\"^0.4.11\"}],\"features\":{\"_nightly\":[],\"alloc\":[],\"default\":[\"std\"],\"detect-env\":[\"std\"],\"detect-tty\":[\"is-terminal\",\"std\"],\"hyperlink\":[\"std\"],\"std\":[\"alloc\"]}}", diff --git a/NOTICE b/NOTICE index 2a64a45aaa..2805899d56 100644 --- a/NOTICE +++ b/NOTICE @@ -4,6 +4,3 @@ Copyright 2025 OpenAI This project includes code derived from [Ratatui](https://github.com/ratatui/ratatui), licensed under the MIT license. Copyright (c) 2016-2022 Florian Dehau Copyright (c) 2023-2025 The Ratatui Developers - -This project includes Meriyah parser assets from [meriyah](https://github.com/meriyah/meriyah), licensed under the ISC license. -Copyright (c) 2019 and later, KFlash and others. diff --git a/codex-cli/.dockerignore b/codex-cli/.dockerignore deleted file mode 100644 index c2658d7d1b..0000000000 --- a/codex-cli/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ diff --git a/codex-cli/Dockerfile b/codex-cli/Dockerfile deleted file mode 100644 index 21a90a4838..0000000000 --- a/codex-cli/Dockerfile +++ /dev/null @@ -1,59 +0,0 @@ -FROM node:24-slim - -ARG TZ -ENV TZ="$TZ" - -# Install basic development tools, ca-certificates, and iptables/ipset, then clean up apt cache to reduce image size -RUN apt-get update && apt-get install -y --no-install-recommends \ - aggregate \ - ca-certificates \ - curl \ - dnsutils \ - fzf \ - gh \ - git \ - gnupg2 \ - iproute2 \ - ipset \ - iptables \ - jq \ - less \ - man-db \ - procps \ - unzip \ - ripgrep \ - zsh \ - && rm -rf /var/lib/apt/lists/* - -# Ensure default node user has access to /usr/local/share -RUN mkdir -p /usr/local/share/npm-global && \ - chown -R node:node /usr/local/share - -ARG USERNAME=node - -# Set up non-root user -USER node - -# Install global packages -ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global -ENV PATH=$PATH:/usr/local/share/npm-global/bin - -# Install codex -COPY dist/codex.tgz codex.tgz -RUN npm install -g codex.tgz \ - && npm cache clean --force \ - && rm -rf /usr/local/share/npm-global/lib/node_modules/codex-cli/node_modules/.cache \ - && rm -rf /usr/local/share/npm-global/lib/node_modules/codex-cli/tests \ - && rm -rf /usr/local/share/npm-global/lib/node_modules/codex-cli/docs - -# Inside the container we consider the environment already sufficiently locked -# down, therefore instruct Codex CLI to allow running without sandboxing. -ENV CODEX_UNSAFE_ALLOW_NO_SANDBOX=1 - -# Copy and set up firewall script as root. -USER root -COPY scripts/init_firewall.sh /usr/local/bin/ -RUN chmod 500 /usr/local/bin/init_firewall.sh - -# Drop back to non-root. -USER node diff --git a/codex-cli/package.json b/codex-cli/package.json index ee2c2a7c7d..5fbac8300b 100644 --- a/codex-cli/package.json +++ b/codex-cli/package.json @@ -18,5 +18,5 @@ "url": "git+https://github.com/openai/codex.git", "directory": "codex-cli" }, - "packageManager": "pnpm@10.29.3+sha512.498e1fb4cca5aa06c1dcf2611e6fafc50972ffe7189998c409e90de74566444298ffe43e6cd2acdc775ba1aa7cc5e092a8b7054c811ba8c5770f84693d33d2dc" + "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319" } diff --git a/codex-cli/scripts/build_container.sh b/codex-cli/scripts/build_container.sh deleted file mode 100755 index d4d29f6b34..0000000000 --- a/codex-cli/scripts/build_container.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -SCRIPT_DIR=$(realpath "$(dirname "$0")") -trap "popd >> /dev/null" EXIT -pushd "$SCRIPT_DIR/.." >> /dev/null || { - echo "Error: Failed to change directory to $SCRIPT_DIR/.." - exit 1 -} -pnpm install -pnpm run build -rm -rf ./dist/openai-codex-*.tgz -pnpm pack --pack-destination ./dist -mv ./dist/openai-codex-*.tgz ./dist/codex.tgz -docker build -t codex -f "./Dockerfile" . diff --git a/codex-rs/.cargo/audit.toml b/codex-rs/.cargo/audit.toml index 3760d28648..4d9e4b81ed 100644 --- a/codex-rs/.cargo/audit.toml +++ b/codex-rs/.cargo/audit.toml @@ -6,5 +6,4 @@ ignore = [ "RUSTSEC-2024-0436", # paste 1.0.15 via starlark/ratatui; upstream crate is unmaintained "RUSTSEC-2024-0320", # yaml-rust via syntect; remove when syntect drops or updates it "RUSTSEC-2025-0141", # bincode via syntect; remove when syntect drops or updates it - "RUSTSEC-2026-0097", # rand 0.8.5 via age/codex-secrets and zbus/keyring; remove when transitive deps move to rand >=0.9.3 ] diff --git a/codex-rs/.config/nextest.toml b/codex-rs/.config/nextest.toml index 3f68bbbe96..86d00e4637 100644 --- a/codex-rs/.config/nextest.toml +++ b/codex-rs/.config/nextest.toml @@ -8,6 +8,11 @@ max-threads = 1 [test-groups.app_server_integration] max-threads = 1 +[test-groups.core_apply_patch_cli_integration] +max-threads = 1 + +[test-groups.windows_sandbox_legacy_sessions] +max-threads = 1 [[profile.default.overrides]] # Do not add new tests here @@ -27,3 +32,15 @@ test-group = 'app_server_protocol_codegen' # Keep the library unit tests parallel. filter = 'package(codex-app-server) & kind(test)' test-group = 'app_server_integration' + +[[profile.default.overrides]] +# These tests exercise full Codex turns and apply_patch execution, and they are +# sensitive to Windows runner process-startup stalls when many cases launch at once. +filter = 'package(codex-core) & kind(test) & test(apply_patch_cli)' +test-group = 'core_apply_patch_cli_integration' + +[[profile.default.overrides]] +# These tests create restricted-token Windows child processes and private desktops. +# Serialize them to avoid exhausting Windows session/global desktop resources in CI. +filter = 'package(codex-windows-sandbox) & test(legacy_)' +test-group = 'windows_sandbox_legacy_sessions' diff --git a/codex-rs/BUILD.bazel b/codex-rs/BUILD.bazel index 66c7ebcb61..c32068a826 100644 --- a/codex-rs/BUILD.bazel +++ b/codex-rs/BUILD.bazel @@ -1,6 +1,5 @@ exports_files([ "clippy.toml", - "node-version.txt", ]) filegroup( diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index de908f05fc..a00873ee48 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -749,6 +749,48 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-config" +version = "1.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96571e6996817bf3d58f6b569e4b9fd2e9d2fcf9f7424eed07b2ce9bb87535e5" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.4.0", + "ring", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cd362783681b15d136480ad555a099e82ecd8e2d10a841e14dfd0078d67fee3" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + [[package]] name = "aws-lc-rs" version = "1.16.2" @@ -772,6 +814,302 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "aws-runtime" +version = "1.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81b5b2898f6798ad58f484856768bca817e3cd9de0974c24ae0f1113fe88f1b" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http-body 0.4.6", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.91.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee6402a36f27b52fe67661c6732d684b2635152b676aa2babbfb5204f99115d" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.93.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45a7f750bbd170ee3677671ad782d90b894548f4e4ae168302c57ec9de5cb3e" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.95.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55542378e419558e6b1f398ca70adb0b2088077e79ad9f14eb09441f2f7b2164" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e523e1c4e8e7e8ff219d732988e22bfeae8a1cafdbe6d9eca1546fa080be7c" +dependencies = [ + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "percent-encoding", + "sha2", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-http" +version = "0.62.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826141069295752372f8203c17f28e30c464d22899a43a0c9fd9c458d469c88b" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2", + "http 1.4.0", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.61.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49fa1213db31ac95288d981476f78d05d9cbb0353d22cdf3472cc05bb02f6551" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f616c3f2260612fe44cede278bafa18e73e6479c4e393e2c4518cf2a9a228a" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae5d689cf437eae90460e944a58b5668530d433b4ff85789e69d2f2a556e057d" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a392db6c583ea4a912538afb86b7be7c5d8887d91604f50eb55c262ee1b4a5f5" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71a13df6ada0aafbf21a73bdfcdf9324cfa9df77d96b8446045be3cde61b42e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11b2f670422ff42bf7065031e72b45bc52a3508bd089f743ea90731ca2b6ea57" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d980627d2dd7bfc32a3c025685a033eeab8d365cc840c631ef59d1b8f428164" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "axum" version = "0.8.8" @@ -784,7 +1122,7 @@ dependencies = [ "form_urlencoded", "futures-util", "http 1.4.0", - "http-body", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-util", @@ -817,7 +1155,7 @@ dependencies = [ "bytes", "futures-core", "http 1.4.0", - "http-body", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -860,6 +1198,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" version = "1.8.3" @@ -946,6 +1294,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1050,6 +1407,16 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "bytestring" version = "1.5.0" @@ -1381,6 +1748,25 @@ dependencies = [ "unicode-width 0.2.1", ] +[[package]] +name = "codex-agent-identity" +version = "0.0.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "chrono", + "codex-protocol", + "crypto_box", + "ed25519-dalek", + "jsonwebtoken", + "pretty_assertions", + "rand 0.9.3", + "reqwest", + "serde", + "serde_json", + "sha2", +] + [[package]] name = "codex-analytics" version = "0.0.0" @@ -1388,6 +1774,7 @@ dependencies = [ "codex-app-server-protocol", "codex-git-utils", "codex-login", + "codex-model-provider", "codex-plugin", "codex-protocol", "codex-utils-absolute-path", @@ -1455,6 +1842,7 @@ dependencies = [ "chrono", "clap", "codex-analytics", + "codex-api", "codex-app-server-protocol", "codex-arg0", "codex-backend-client", @@ -1471,6 +1859,7 @@ dependencies = [ "codex-git-utils", "codex-login", "codex-mcp", + "codex-model-provider", "codex-model-provider-info", "codex-models-manager", "codex-otel", @@ -1482,6 +1871,7 @@ dependencies = [ "codex-state", "codex-thread-store", "codex-tools", + "codex-uds", "codex-utils-absolute-path", "codex-utils-cargo-bin", "codex-utils-cli", @@ -1639,14 +2029,31 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "codex-aws-auth" +version = "0.0.0" +dependencies = [ + "aws-config", + "aws-credential-types", + "aws-sigv4", + "aws-types", + "bytes", + "http 1.4.0", + "pretty_assertions", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "codex-backend-client" version = "0.0.0" dependencies = [ "anyhow", + "codex-api", "codex-backend-openapi-models", "codex-client", "codex-login", + "codex-model-provider", "codex-protocol", "pretty_assertions", "reqwest", @@ -1670,11 +2077,11 @@ dependencies = [ "anyhow", "clap", "codex-app-server-protocol", - "codex-config", "codex-connectors", "codex-core", "codex-git-utils", "codex-login", + "codex-model-provider", "codex-utils-cargo-bin", "codex-utils-cli", "pretty_assertions", @@ -1693,7 +2100,6 @@ dependencies = [ "assert_matches", "clap", "clap_complete", - "codex-api", "codex-app-server", "codex-app-server-protocol", "codex-app-server-test-client", @@ -1702,6 +2108,7 @@ dependencies = [ "codex-cloud-tasks", "codex-config", "codex-core", + "codex-core-plugins", "codex-exec", "codex-exec-server", "codex-execpolicy", @@ -1709,11 +2116,11 @@ dependencies = [ "codex-login", "codex-mcp", "codex-mcp-server", - "codex-model-provider", "codex-models-manager", "codex-protocol", "codex-responses-api-proxy", "codex-rmcp-client", + "codex-rollout-trace", "codex-sandboxing", "codex-state", "codex-stdio-to-uds", @@ -1801,7 +2208,6 @@ version = "0.0.0" dependencies = [ "anyhow", "async-trait", - "base64 0.22.1", "chrono", "clap", "codex-client", @@ -1810,6 +2216,7 @@ dependencies = [ "codex-core", "codex-git-utils", "codex-login", + "codex-model-provider", "codex-tui", "codex-utils-cli", "crossterm", @@ -1834,6 +2241,7 @@ dependencies = [ "anyhow", "async-trait", "chrono", + "codex-api", "codex-backend-client", "codex-git-utils", "serde", @@ -1878,20 +2286,26 @@ version = "0.0.0" dependencies = [ "anyhow", "async-trait", + "base64 0.22.1", "codex-app-server-protocol", "codex-execpolicy", "codex-features", + "codex-file-system", + "codex-git-utils", "codex-model-provider-info", "codex-network-proxy", "codex-protocol", "codex-utils-absolute-path", "codex-utils-path", + "core-foundation 0.9.4", "dns-lookup", + "dunce", "futures", "gethostname", "libc", "multimap", "pretty_assertions", + "prost 0.14.3", "schemars 0.8.22", "serde", "serde_json", @@ -1900,11 +2314,16 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tokio-stream", "toml 0.9.11+spec-1.1.0", "toml_edit 0.24.0+spec-1.1.0", + "tonic", + "tonic-prost", + "tonic-prost-build", "tracing", "wildmatch", "winapi-util", + "windows-sys 0.52.0", ] [[package]] @@ -1951,6 +2370,8 @@ dependencies = [ "codex-hooks", "codex-login", "codex-mcp", + "codex-memories-read", + "codex-memories-write", "codex-model-provider", "codex-model-provider-info", "codex-models-manager", @@ -1961,6 +2382,7 @@ dependencies = [ "codex-response-debug-context", "codex-rmcp-client", "codex-rollout", + "codex-rollout-trace", "codex-sandboxing", "codex-secrets", "codex-shell-command", @@ -1984,7 +2406,6 @@ dependencies = [ "codex-utils-string", "codex-utils-template", "codex-windows-sandbox", - "core-foundation 0.9.4", "core_test_support", "csv", "ctor 0.6.3", @@ -2035,9 +2456,7 @@ dependencies = [ "walkdir", "which 8.0.0", "whoami", - "windows-sys 0.52.0", "wiremock", - "zip 2.4.2", "zstd 0.13.3", ] @@ -2045,6 +2464,7 @@ dependencies = [ name = "codex-core-plugins" version = "0.0.0" dependencies = [ + "anyhow", "chrono", "codex-app-server-protocol", "codex-config", @@ -2052,11 +2472,14 @@ dependencies = [ "codex-exec-server", "codex-git-utils", "codex-login", + "codex-model-provider", + "codex-otel", "codex-plugin", "codex-protocol", "codex-utils-absolute-path", "codex-utils-plugins", "dirs", + "libc", "pretty_assertions", "reqwest", "serde", @@ -2067,6 +2490,8 @@ dependencies = [ "toml 0.9.11+spec-1.1.0", "tracing", "url", + "wiremock", + "zip 2.4.2", ] [[package]] @@ -2079,6 +2504,7 @@ dependencies = [ "codex-config", "codex-exec-server", "codex-login", + "codex-model-provider", "codex-otel", "codex-protocol", "codex-skills", @@ -2115,6 +2541,7 @@ dependencies = [ name = "codex-device-key" version = "0.0.0" dependencies = [ + "async-trait", "base64 0.22.1", "p256", "pretty_assertions", @@ -2122,6 +2549,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", + "tokio", "url", ] @@ -2137,6 +2565,7 @@ dependencies = [ "codex-apply-patch", "codex-arg0", "codex-cloud-requirements", + "codex-config", "codex-core", "codex-feedback", "codex-git-utils", @@ -2177,8 +2606,10 @@ dependencies = [ "arc-swap", "async-trait", "base64 0.22.1", + "bytes", "codex-app-server-protocol", - "codex-config", + "codex-client", + "codex-file-system", "codex-protocol", "codex-sandboxing", "codex-test-binary-support", @@ -2187,6 +2618,7 @@ dependencies = [ "ctor 0.6.3", "futures", "pretty_assertions", + "reqwest", "serde", "serde_json", "serial_test", @@ -2195,6 +2627,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-tungstenite", + "tokio-util", "tracing", "uuid", ] @@ -2287,14 +2720,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-file-system" +version = "0.0.0" +dependencies = [ + "async-trait", + "codex-protocol", + "codex-utils-absolute-path", + "serde", +] + [[package]] name = "codex-git-utils" version = "0.0.0" dependencies = [ "anyhow", - "assert_matches", "chrono", - "codex-exec-server", + "codex-file-system", "codex-protocol", "codex-utils-absolute-path", "futures", @@ -2354,7 +2796,6 @@ version = "0.0.0" dependencies = [ "cc", "clap", - "codex-config", "codex-core", "codex-protocol", "codex-sandboxing", @@ -2394,6 +2835,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "chrono", + "codex-agent-identity", "codex-app-server-protocol", "codex-client", "codex-config", @@ -2432,15 +2874,16 @@ version = "0.0.0" dependencies = [ "anyhow", "async-channel", + "codex-api", "codex-async-utils", "codex-config", "codex-exec-server", "codex-login", + "codex-model-provider", "codex-otel", "codex-plugin", "codex-protocol", "codex-rmcp-client", - "codex-utils-absolute-path", "codex-utils-plugins", "futures", "pretty_assertions", @@ -2490,17 +2933,62 @@ dependencies = [ "wiremock", ] +[[package]] +name = "codex-memories-read" +version = "0.0.0" +dependencies = [ + "codex-protocol", + "codex-shell-command", + "codex-utils-absolute-path", + "codex-utils-output-truncation", + "codex-utils-template", + "pretty_assertions", + "tempfile", + "tokio", +] + +[[package]] +name = "codex-memories-write" +version = "0.0.0" +dependencies = [ + "anyhow", + "chrono", + "codex-git-utils", + "codex-models-manager", + "codex-protocol", + "codex-state", + "codex-utils-absolute-path", + "codex-utils-output-truncation", + "codex-utils-template", + "pretty_assertions", + "tempfile", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "codex-model-provider" version = "0.0.0" dependencies = [ "async-trait", + "codex-agent-identity", "codex-api", + "codex-aws-auth", + "codex-client", + "codex-feedback", "codex-login", "codex-model-provider-info", + "codex-models-manager", + "codex-otel", "codex-protocol", + "codex-response-debug-context", "http 1.4.0", "pretty_assertions", + "serde_json", + "tokio", + "tracing", + "wiremock", ] [[package]] @@ -2524,32 +3012,21 @@ dependencies = [ name = "codex-models-manager" version = "0.0.0" dependencies = [ - "base64 0.22.1", + "async-trait", "chrono", - "codex-api", "codex-app-server-protocol", "codex-collaboration-mode-templates", - "codex-config", - "codex-feedback", "codex-login", - "codex-model-provider", - "codex-model-provider-info", "codex-otel", "codex-protocol", - "codex-response-debug-context", - "codex-utils-absolute-path", "codex-utils-output-truncation", "codex-utils-template", - "core_test_support", - "http 1.4.0", "pretty_assertions", "serde", "serde_json", "tempfile", "tokio", "tracing", - "tracing-subscriber", - "wiremock", ] [[package]] @@ -2689,6 +3166,7 @@ dependencies = [ "tracing", "ts-rs", "uuid", + "wildmatch", ] [[package]] @@ -2734,6 +3212,8 @@ version = "0.0.0" dependencies = [ "anyhow", "axum", + "bytes", + "codex-api", "codex-client", "codex-config", "codex-exec-server", @@ -2793,11 +3273,14 @@ name = "codex-rollout-trace" version = "0.0.0" dependencies = [ "anyhow", + "codex-code-mode", "codex-protocol", "pretty_assertions", "serde", "serde_json", "tempfile", + "tracing", + "uuid", ] [[package]] @@ -2964,6 +3447,7 @@ dependencies = [ "tonic", "tonic-prost", "tonic-prost-build", + "tracing", "uuid", ] @@ -3003,6 +3487,7 @@ dependencies = [ "codex-cloud-requirements", "codex-config", "codex-connectors", + "codex-core-plugins", "codex-core-skills", "codex-exec-server", "codex-features", @@ -3779,9 +4264,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "crypto_box" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16182b4f39a82ec8a6851155cc4c0cda3065bb1db33651726a29e1951de0f009" +dependencies = [ + "aead", + "blake2", + "crypto_secretbox", + "curve25519-dalek", + "salsa20", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto_secretbox" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1" +dependencies = [ + "aead", + "cipher", + "generic-array", + "poly1305", + "salsa20", + "subtle", + "zeroize", +] + [[package]] name = "csv" version = "1.4.0" @@ -3838,6 +4354,7 @@ dependencies = [ "cfg-if", "cpufeatures", "curve25519-dalek-derive", + "digest", "fiat-crypto", "rustc_version", "subtle", @@ -4467,6 +4984,30 @@ dependencies = [ "spki", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -6422,6 +6963,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -6441,7 +6993,7 @@ dependencies = [ "bytes", "futures-core", "http 1.4.0", - "http-body", + "http-body 1.0.1", "pin-project-lite", ] @@ -6475,7 +7027,7 @@ dependencies = [ "futures-core", "h2", "http 1.4.0", - "http-body", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -6544,7 +7096,7 @@ dependencies = [ "futures-channel", "futures-util", "http 1.4.0", - "http-body", + "http-body 1.0.1", "hyper", "ipnet", "libc", @@ -8165,7 +8717,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -8633,9 +9185,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "owo-colors" version = "4.3.0" @@ -9594,7 +10152,7 @@ dependencies = [ "const_format", "fnv", "http 1.4.0", - "http-body", + "http-body 1.0.1", "http-body-util", "itoa", "memchr", @@ -9995,7 +10553,7 @@ dependencies = [ "futures-util", "h2", "http 1.4.0", - "http-body", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-rustls", @@ -10083,7 +10641,7 @@ dependencies = [ "chrono", "futures", "http 1.4.0", - "http-body", + "http-body 1.0.1", "http-body-util", "oauth2", "pastey", @@ -10296,9 +10854,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -11362,7 +11920,7 @@ checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" dependencies = [ "bytes", "futures-util", - "http-body", + "http-body 1.0.1", "http-body-util", "pin-project-lite", ] @@ -12238,7 +12796,7 @@ dependencies = [ "bytes", "h2", "http 1.4.0", - "http-body", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-timeout", @@ -12325,7 +12883,7 @@ dependencies = [ "bytes", "futures-util", "http 1.4.0", - "http-body", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower", @@ -12869,6 +13427,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "vt100" version = "0.16.2" @@ -13255,7 +13819,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -13935,6 +14499,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "xz2" version = "0.1.7" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index d70af322a1..1bbb138347 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -1,6 +1,8 @@ [workspace] members = [ + "aws-auth", "analytics", + "agent-identity", "backend-client", "ansi-escape", "async-utils", @@ -34,6 +36,7 @@ members = [ "hooks", "secrets", "exec", + "file-system", "exec-server", "execpolicy", "execpolicy-legacy", @@ -44,6 +47,8 @@ members = [ "login", "codex-mcp", "mcp-server", + "memories/read", + "memories/write", "model-provider-info", "models-manager", "network-proxy", @@ -111,8 +116,10 @@ license = "Apache-2.0" # Internal app_test_support = { path = "app-server/tests/common" } codex-analytics = { path = "analytics" } +codex-agent-identity = { path = "agent-identity" } codex-ansi-escape = { path = "ansi-escape" } codex-api = { path = "codex-api" } +codex-aws-auth = { path = "aws-auth" } codex-app-server = { path = "app-server" } codex-app-server-client = { path = "app-server-client" } codex-app-server-protocol = { path = "app-server-protocol" } @@ -136,6 +143,7 @@ codex-core-plugins = { path = "core-plugins" } codex-core-skills = { path = "core-skills" } codex-device-key = { path = "device-key" } codex-exec = { path = "exec" } +codex-file-system = { path = "file-system" } codex-exec-server = { path = "exec-server" } codex-execpolicy = { path = "execpolicy" } codex-experimental-api-macros = { path = "codex-experimental-api-macros" } @@ -149,6 +157,8 @@ codex-keyring-store = { path = "keyring-store" } codex-linux-sandbox = { path = "linux-sandbox" } codex-lmstudio = { path = "lmstudio" } codex-login = { path = "login" } +codex-memories-read = { path = "memories/read" } +codex-memories-write = { path = "memories/write" } codex-mcp = { path = "codex-mcp" } codex-mcp-server = { path = "mcp-server" } codex-model-provider-info = { path = "model-provider-info" } @@ -165,6 +175,7 @@ codex-responses-api-proxy = { path = "responses-api-proxy" } codex-response-debug-context = { path = "response-debug-context" } codex-rmcp-client = { path = "rmcp-client" } codex-rollout = { path = "rollout" } +codex-rollout-trace = { path = "rollout-trace" } codex-sandboxing = { path = "sandboxing" } codex-secrets = { path = "secrets" } codex-shell-command = { path = "shell-command" } @@ -218,6 +229,10 @@ async-channel = "2.3.1" async-io = "2.6.0" async-stream = "0.3.6" async-trait = "0.1.89" +aws-config = "1" +aws-credential-types = "1" +aws-sigv4 = "1" +aws-types = "1" axum = { version = "0.8", default-features = false } base64 = "0.22.1" bm25 = "2.3.2" @@ -229,6 +244,7 @@ clap_complete = "4" color-eyre = "0.6.3" constant_time_eq = "0.3.1" crossbeam-channel = "0.5.15" +crypto_box = { version = "0.9.1", features = ["seal"] } crossterm = "0.28.1" csv = "1.3.1" ctor = "0.6.3" @@ -239,6 +255,7 @@ dirs = "6" dns-lookup = "3.0.1" dotenvy = "0.15.7" dunce = "1.0.4" +ed25519-dalek = { version = "2.2.0", features = ["pkcs8"] } encoding_rs = "0.8.35" env-flags = "0.1.1" env_logger = "0.11.9" diff --git a/codex-rs/README.md b/codex-rs/README.md index 2ad7158f98..31bae56235 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -94,7 +94,7 @@ In `workspace-write`, Codex also includes `~/.codex/memories` in its writable ro This folder is the root of a Cargo workspace. It contains quite a bit of experimental code, but here are the key crates: -- [`core/`](./core) contains the business logic for Codex. Ultimately, we hope this to be a library crate that is generally useful for building other Rust/native applications that use Codex. +- [`core/`](./core) contains the business logic for Codex. Ultimately, we hope this becomes a library crate that is generally useful for building other Rust/native applications that use Codex. - [`exec/`](./exec) "headless" CLI for use in automation. - [`tui/`](./tui) CLI that launches a fullscreen TUI built with [Ratatui](https://ratatui.rs/). - [`cli/`](./cli) CLI multitool that provides the aforementioned CLIs via subcommands. diff --git a/codex-rs/agent-identity/BUILD.bazel b/codex-rs/agent-identity/BUILD.bazel new file mode 100644 index 0000000000..d1363c468f --- /dev/null +++ b/codex-rs/agent-identity/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "agent-identity", + crate_name = "codex_agent_identity", +) diff --git a/codex-rs/agent-identity/Cargo.toml b/codex-rs/agent-identity/Cargo.toml new file mode 100644 index 0000000000..4610d6ec9b --- /dev/null +++ b/codex-rs/agent-identity/Cargo.toml @@ -0,0 +1,30 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-agent-identity" +version.workspace = true + +[lib] +doctest = false +name = "codex_agent_identity" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +base64 = { workspace = true } +chrono = { workspace = true } +codex-protocol = { workspace = true } +crypto_box = { workspace = true } +ed25519-dalek = { workspace = true } +jsonwebtoken = { workspace = true } +rand = { workspace = true } +reqwest = { workspace = true, features = ["json"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha2 = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/agent-identity/src/lib.rs b/codex-rs/agent-identity/src/lib.rs new file mode 100644 index 0000000000..bf139f7870 --- /dev/null +++ b/codex-rs/agent-identity/src/lib.rs @@ -0,0 +1,599 @@ +use std::collections::BTreeMap; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use chrono::SecondsFormat; +use chrono::Utc; +use codex_protocol::account::PlanType as AccountPlanType; +use codex_protocol::protocol::SessionSource; +use crypto_box::SecretKey as Curve25519SecretKey; +use ed25519_dalek::Signer as _; +use ed25519_dalek::SigningKey; +use ed25519_dalek::VerifyingKey; +use ed25519_dalek::pkcs8::DecodePrivateKey; +use ed25519_dalek::pkcs8::EncodePrivateKey; +use jsonwebtoken::Algorithm; +use jsonwebtoken::DecodingKey; +use jsonwebtoken::Validation; +use rand::TryRngCore; +use rand::rngs::OsRng; +use serde::Deserialize; +use serde::Serialize; +use serde::de::DeserializeOwned; +use sha2::Digest as _; +use sha2::Sha512; + +const AGENT_TASK_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(30); + +/// Stored key material for a registered agent identity. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AgentIdentityKey<'a> { + pub agent_runtime_id: &'a str, + pub private_key_pkcs8_base64: &'a str, +} + +/// Task binding to use when constructing a task-scoped AgentAssertion. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AgentTaskAuthorizationTarget<'a> { + pub agent_runtime_id: &'a str, + pub task_id: &'a str, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct AgentBillOfMaterials { + pub agent_version: String, + pub agent_harness_id: String, + pub running_location: String, +} + +pub struct GeneratedAgentKeyMaterial { + pub private_key_pkcs8_base64: String, + pub public_key_ssh: String, +} + +/// Claims carried by an Agent Identity JWT. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct AgentIdentityJwtClaims { + pub agent_runtime_id: String, + pub agent_private_key: String, + pub account_id: String, + pub chatgpt_user_id: String, + pub email: String, + pub plan_type: AccountPlanType, + pub chatgpt_account_is_fedramp: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +struct AgentAssertionEnvelope { + agent_runtime_id: String, + task_id: String, + timestamp: String, + signature: String, +} + +#[derive(Serialize)] +struct RegisterTaskRequest { + timestamp: String, + signature: String, +} + +#[derive(Deserialize)] +struct RegisterTaskResponse { + #[serde(default)] + task_id: Option, + #[serde(default, rename = "taskId")] + task_id_camel: Option, + #[serde(default)] + encrypted_task_id: Option, + #[serde(default, rename = "encryptedTaskId")] + encrypted_task_id_camel: Option, +} + +pub fn authorization_header_for_agent_task( + key: AgentIdentityKey<'_>, + target: AgentTaskAuthorizationTarget<'_>, +) -> Result { + anyhow::ensure!( + key.agent_runtime_id == target.agent_runtime_id, + "agent task runtime {} does not match stored agent identity {}", + target.agent_runtime_id, + key.agent_runtime_id + ); + + let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); + let envelope = AgentAssertionEnvelope { + agent_runtime_id: target.agent_runtime_id.to_string(), + task_id: target.task_id.to_string(), + timestamp: timestamp.clone(), + signature: sign_agent_assertion_payload(key, target.task_id, ×tamp)?, + }; + let serialized_assertion = serialize_agent_assertion(&envelope)?; + Ok(format!("AgentAssertion {serialized_assertion}")) +} + +pub fn decode_agent_identity_jwt( + jwt: &str, + public_key_base64: Option<&str>, +) -> Result { + let Some(public_key_base64) = public_key_base64 else { + return decode_agent_identity_jwt_payload(jwt); + }; + + let mut validation = Validation::new(Algorithm::EdDSA); + validation.required_spec_claims.clear(); + validation.validate_exp = false; + validation.validate_aud = false; + + let public_key = BASE64_STANDARD + .decode(public_key_base64) + .context("agent identity JWT public key is not valid base64")?; + let decoding_key = DecodingKey::from_ed_der(&public_key); + + jsonwebtoken::decode::(jwt, &decoding_key, &validation) + .map(|data| data.claims) + .context("failed to decode agent identity JWT") +} + +fn decode_agent_identity_jwt_payload(jwt: &str) -> Result { + let mut parts = jwt.split('.'); + let (_header_b64, payload_b64, _sig_b64) = match (parts.next(), parts.next(), parts.next()) { + (Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s), + _ => anyhow::bail!("invalid agent identity JWT format"), + }; + anyhow::ensure!(parts.next().is_none(), "invalid agent identity JWT format"); + + let payload_bytes = URL_SAFE_NO_PAD + .decode(payload_b64) + .context("agent identity JWT payload is not valid base64url")?; + serde_json::from_slice(&payload_bytes).context("agent identity JWT payload is not valid JSON") +} + +pub fn sign_task_registration_payload( + key: AgentIdentityKey<'_>, + timestamp: &str, +) -> Result { + let signing_key = signing_key_from_private_key_pkcs8_base64(key.private_key_pkcs8_base64)?; + let payload = format!("{}:{timestamp}", key.agent_runtime_id); + Ok(BASE64_STANDARD.encode(signing_key.sign(payload.as_bytes()).to_bytes())) +} + +pub async fn register_agent_task( + client: &reqwest::Client, + chatgpt_base_url: &str, + key: AgentIdentityKey<'_>, +) -> Result { + let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); + let request = RegisterTaskRequest { + signature: sign_task_registration_payload(key, ×tamp)?, + timestamp, + }; + let url = agent_task_registration_url(chatgpt_base_url, key.agent_runtime_id); + + let response = client + .post(url) + .timeout(AGENT_TASK_REGISTRATION_TIMEOUT) + .json(&request) + .send() + .await + .context("failed to register agent task")?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + let body = if body.len() > 512 { + format!("{}...", body.chars().take(512).collect::()) + } else { + body + }; + anyhow::bail!("failed to register agent task with status {status}: {body}"); + } + + let response = response + .json() + .await + .context("failed to decode agent task registration response")?; + + task_id_from_register_task_response(key, response) +} + +fn task_id_from_register_task_response( + key: AgentIdentityKey<'_>, + response: RegisterTaskResponse, +) -> Result { + if let Some(task_id) = response.task_id.or(response.task_id_camel) { + return Ok(task_id); + } + let encrypted_task_id = response + .encrypted_task_id + .or(response.encrypted_task_id_camel) + .context("agent task registration response omitted task id")?; + decrypt_task_id_response(key, &encrypted_task_id) +} + +pub fn decrypt_task_id_response( + key: AgentIdentityKey<'_>, + encrypted_task_id: &str, +) -> Result { + let signing_key = signing_key_from_private_key_pkcs8_base64(key.private_key_pkcs8_base64)?; + let ciphertext = BASE64_STANDARD + .decode(encrypted_task_id) + .context("encrypted task id is not valid base64")?; + let plaintext = curve25519_secret_key_from_signing_key(&signing_key) + .unseal(&ciphertext) + .map_err(|_| anyhow::anyhow!("failed to decrypt encrypted task id"))?; + String::from_utf8(plaintext).context("decrypted task id is not valid UTF-8") +} + +pub fn generate_agent_key_material() -> Result { + let mut secret_key_bytes = [0u8; 32]; + OsRng + .try_fill_bytes(&mut secret_key_bytes) + .context("failed to generate agent identity private key bytes")?; + let signing_key = SigningKey::from_bytes(&secret_key_bytes); + let private_key_pkcs8 = signing_key + .to_pkcs8_der() + .context("failed to encode agent identity private key as PKCS#8")?; + + Ok(GeneratedAgentKeyMaterial { + private_key_pkcs8_base64: BASE64_STANDARD.encode(private_key_pkcs8.as_bytes()), + public_key_ssh: encode_ssh_ed25519_public_key(&signing_key.verifying_key()), + }) +} + +pub fn public_key_ssh_from_private_key_pkcs8_base64( + private_key_pkcs8_base64: &str, +) -> Result { + let signing_key = signing_key_from_private_key_pkcs8_base64(private_key_pkcs8_base64)?; + Ok(encode_ssh_ed25519_public_key(&signing_key.verifying_key())) +} + +pub fn verifying_key_from_private_key_pkcs8_base64( + private_key_pkcs8_base64: &str, +) -> Result { + let signing_key = signing_key_from_private_key_pkcs8_base64(private_key_pkcs8_base64)?; + Ok(signing_key.verifying_key()) +} + +pub fn curve25519_secret_key_from_private_key_pkcs8_base64( + private_key_pkcs8_base64: &str, +) -> Result { + let signing_key = signing_key_from_private_key_pkcs8_base64(private_key_pkcs8_base64)?; + Ok(curve25519_secret_key_from_signing_key(&signing_key)) +} + +pub fn agent_registration_url(chatgpt_base_url: &str) -> String { + let trimmed = chatgpt_base_url.trim_end_matches('/'); + format!("{trimmed}/v1/agent/register") +} + +pub fn agent_task_registration_url(chatgpt_base_url: &str, agent_runtime_id: &str) -> String { + let trimmed = chatgpt_base_url.trim_end_matches('/'); + format!("{trimmed}/v1/agent/{agent_runtime_id}/task/register") +} + +pub fn agent_identity_biscuit_url(chatgpt_base_url: &str) -> String { + let trimmed = chatgpt_base_url.trim_end_matches('/'); + format!("{trimmed}/authenticate_app_v2") +} + +pub fn agent_identity_request_id() -> Result { + let mut request_id_bytes = [0u8; 16]; + OsRng + .try_fill_bytes(&mut request_id_bytes) + .context("failed to generate agent identity request id")?; + Ok(format!( + "codex-agent-identity-{}", + URL_SAFE_NO_PAD.encode(request_id_bytes) + )) +} + +pub fn normalize_chatgpt_base_url(chatgpt_base_url: &str) -> String { + let mut base_url = chatgpt_base_url.trim_end_matches('/').to_string(); + for suffix in [ + "/wham/remote/control/server/enroll", + "/wham/remote/control/server", + ] { + if let Some(stripped) = base_url.strip_suffix(suffix) { + base_url = stripped.to_string(); + break; + } + } + if let Some(stripped) = base_url.strip_suffix("/codex") { + base_url = stripped.to_string(); + } + if (base_url.starts_with("https://chatgpt.com") + || base_url.starts_with("https://chat.openai.com")) + && !base_url.contains("/backend-api") + { + base_url = format!("{base_url}/backend-api"); + } + base_url +} + +pub fn build_abom(session_source: SessionSource) -> AgentBillOfMaterials { + AgentBillOfMaterials { + agent_version: env!("CARGO_PKG_VERSION").to_string(), + agent_harness_id: match &session_source { + SessionSource::VSCode => "codex-app".to_string(), + SessionSource::Cli + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::SubAgent(_) + | SessionSource::Unknown => "codex-cli".to_string(), + }, + running_location: format!("{}-{}", session_source, std::env::consts::OS), + } +} + +pub fn encode_ssh_ed25519_public_key(verifying_key: &VerifyingKey) -> String { + let mut blob = Vec::with_capacity(4 + 11 + 4 + 32); + append_ssh_string(&mut blob, b"ssh-ed25519"); + append_ssh_string(&mut blob, verifying_key.as_bytes()); + format!("ssh-ed25519 {}", BASE64_STANDARD.encode(blob)) +} + +fn sign_agent_assertion_payload( + key: AgentIdentityKey<'_>, + task_id: &str, + timestamp: &str, +) -> Result { + let signing_key = signing_key_from_private_key_pkcs8_base64(key.private_key_pkcs8_base64)?; + let payload = format!("{}:{task_id}:{timestamp}", key.agent_runtime_id); + Ok(BASE64_STANDARD.encode(signing_key.sign(payload.as_bytes()).to_bytes())) +} + +fn serialize_agent_assertion(envelope: &AgentAssertionEnvelope) -> Result { + let payload = serde_json::to_vec(&BTreeMap::from([ + ("agent_runtime_id", envelope.agent_runtime_id.as_str()), + ("signature", envelope.signature.as_str()), + ("task_id", envelope.task_id.as_str()), + ("timestamp", envelope.timestamp.as_str()), + ])) + .context("failed to serialize agent assertion envelope")?; + Ok(URL_SAFE_NO_PAD.encode(payload)) +} + +fn curve25519_secret_key_from_signing_key(signing_key: &SigningKey) -> Curve25519SecretKey { + let digest = Sha512::digest(signing_key.to_bytes()); + let mut secret_key = [0u8; 32]; + secret_key.copy_from_slice(&digest[..32]); + secret_key[0] &= 248; + secret_key[31] &= 127; + secret_key[31] |= 64; + Curve25519SecretKey::from(secret_key) +} + +fn append_ssh_string(buf: &mut Vec, value: &[u8]) { + buf.extend_from_slice(&(value.len() as u32).to_be_bytes()); + buf.extend_from_slice(value); +} + +fn signing_key_from_private_key_pkcs8_base64(private_key_pkcs8_base64: &str) -> Result { + let private_key = BASE64_STANDARD + .decode(private_key_pkcs8_base64) + .context("stored agent identity private key is not valid base64")?; + SigningKey::from_pkcs8_der(&private_key) + .context("stored agent identity private key is not valid PKCS#8") +} + +#[cfg(test)] +mod tests { + use base64::Engine as _; + use ed25519_dalek::Signature; + use ed25519_dalek::Verifier as _; + use jsonwebtoken::EncodingKey; + use jsonwebtoken::Header; + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn authorization_header_for_agent_task_serializes_signed_agent_assertion() { + let signing_key = SigningKey::from_bytes(&[7u8; 32]); + let private_key = signing_key + .to_pkcs8_der() + .expect("encode test key material"); + let key = AgentIdentityKey { + agent_runtime_id: "agent-123", + private_key_pkcs8_base64: &BASE64_STANDARD.encode(private_key.as_bytes()), + }; + let target = AgentTaskAuthorizationTarget { + agent_runtime_id: "agent-123", + task_id: "task-123", + }; + + let header = + authorization_header_for_agent_task(key, target).expect("build agent assertion header"); + let token = header + .strip_prefix("AgentAssertion ") + .expect("agent assertion scheme"); + let payload = URL_SAFE_NO_PAD + .decode(token) + .expect("valid base64url payload"); + let envelope: AgentAssertionEnvelope = + serde_json::from_slice(&payload).expect("valid assertion envelope"); + + assert_eq!( + envelope, + AgentAssertionEnvelope { + agent_runtime_id: "agent-123".to_string(), + task_id: "task-123".to_string(), + timestamp: envelope.timestamp.clone(), + signature: envelope.signature.clone(), + } + ); + let signature_bytes = BASE64_STANDARD + .decode(&envelope.signature) + .expect("valid base64 signature"); + let signature = Signature::from_slice(&signature_bytes).expect("valid signature bytes"); + signing_key + .verifying_key() + .verify( + format!( + "{}:{}:{}", + envelope.agent_runtime_id, envelope.task_id, envelope.timestamp + ) + .as_bytes(), + &signature, + ) + .expect("signature should verify"); + } + + #[test] + fn authorization_header_for_agent_task_rejects_mismatched_runtime() { + let signing_key = SigningKey::from_bytes(&[7u8; 32]); + let private_key = signing_key + .to_pkcs8_der() + .expect("encode test key material"); + let private_key_pkcs8_base64 = BASE64_STANDARD.encode(private_key.as_bytes()); + let key = AgentIdentityKey { + agent_runtime_id: "agent-123", + private_key_pkcs8_base64: &private_key_pkcs8_base64, + }; + let target = AgentTaskAuthorizationTarget { + agent_runtime_id: "agent-456", + task_id: "task-123", + }; + + let error = authorization_header_for_agent_task(key, target) + .expect_err("runtime mismatch should fail"); + + assert_eq!( + error.to_string(), + "agent task runtime agent-456 does not match stored agent identity agent-123" + ); + } + + #[test] + fn decode_agent_identity_jwt_reads_claims() { + let jwt = jwt_with_payload(serde_json::json!({ + "agent_runtime_id": "agent-runtime-id", + "agent_private_key": "private-key", + "account_id": "account-id", + "chatgpt_user_id": "user-id", + "email": "user@example.com", + "plan_type": "pro", + "chatgpt_account_is_fedramp": false, + })); + + let claims = + decode_agent_identity_jwt(&jwt, /*public_key_base64*/ None).expect("JWT should decode"); + + assert_eq!( + claims, + AgentIdentityJwtClaims { + agent_runtime_id: "agent-runtime-id".to_string(), + agent_private_key: "private-key".to_string(), + account_id: "account-id".to_string(), + chatgpt_user_id: "user-id".to_string(), + email: "user@example.com".to_string(), + plan_type: AccountPlanType::Pro, + chatgpt_account_is_fedramp: false, + } + ); + } + + #[test] + fn decode_agent_identity_jwt_verifies_when_public_key_is_present() { + let mut secret_key_bytes = [0u8; 32]; + secret_key_bytes[0] = 1; + let signing_key = SigningKey::from_bytes(&secret_key_bytes); + let private_key_pkcs8 = signing_key + .to_pkcs8_der() + .expect("private key should encode"); + let public_key_base64 = BASE64_STANDARD.encode(signing_key.verifying_key().as_bytes()); + let claims = AgentIdentityJwtClaims { + agent_runtime_id: "agent-runtime-id".to_string(), + agent_private_key: "private-key".to_string(), + account_id: "account-id".to_string(), + chatgpt_user_id: "user-id".to_string(), + email: "user@example.com".to_string(), + plan_type: AccountPlanType::Pro, + chatgpt_account_is_fedramp: false, + }; + let jwt = jsonwebtoken::encode( + &Header::new(Algorithm::EdDSA), + &serde_json::json!({ + "agent_runtime_id": claims.agent_runtime_id, + "agent_private_key": claims.agent_private_key, + "account_id": claims.account_id, + "chatgpt_user_id": claims.chatgpt_user_id, + "email": claims.email, + "plan_type": "pro", + "chatgpt_account_is_fedramp": claims.chatgpt_account_is_fedramp, + }), + &EncodingKey::from_ed_der(private_key_pkcs8.as_bytes()), + ) + .expect("JWT should encode"); + + let expected_claims = AgentIdentityJwtClaims { + agent_runtime_id: "agent-runtime-id".to_string(), + agent_private_key: "private-key".to_string(), + account_id: "account-id".to_string(), + chatgpt_user_id: "user-id".to_string(), + email: "user@example.com".to_string(), + plan_type: AccountPlanType::Pro, + chatgpt_account_is_fedramp: false, + }; + assert_eq!( + decode_agent_identity_jwt(&jwt, Some(&public_key_base64)).expect("JWT should verify"), + expected_claims + ); + } + + #[test] + fn decode_agent_identity_jwt_rejects_wrong_public_key() { + let mut signing_secret_key_bytes = [0u8; 32]; + signing_secret_key_bytes[0] = 1; + let signing_key = SigningKey::from_bytes(&signing_secret_key_bytes); + let private_key_pkcs8 = signing_key + .to_pkcs8_der() + .expect("private key should encode"); + + let mut other_secret_key_bytes = [0u8; 32]; + other_secret_key_bytes[0] = 2; + let other_public_key_base64 = BASE64_STANDARD.encode( + SigningKey::from_bytes(&other_secret_key_bytes) + .verifying_key() + .as_bytes(), + ); + + let jwt = jsonwebtoken::encode( + &Header::new(Algorithm::EdDSA), + &serde_json::json!({ + "agent_runtime_id": "agent-runtime-id", + "agent_private_key": "private-key", + "account_id": "account-id", + "chatgpt_user_id": "user-id", + "email": "user@example.com", + "plan_type": "pro", + "chatgpt_account_is_fedramp": false, + }), + &EncodingKey::from_ed_der(private_key_pkcs8.as_bytes()), + ) + .expect("JWT should encode"); + + decode_agent_identity_jwt(&jwt, Some(&other_public_key_base64)) + .expect_err("JWT should not verify"); + } + + #[test] + fn normalize_chatgpt_base_url_strips_codex_before_backend_api() { + assert_eq!( + normalize_chatgpt_base_url("https://chatgpt.com/codex"), + "https://chatgpt.com/backend-api" + ); + } + + fn jwt_with_payload(payload: serde_json::Value) -> String { + let encode = |bytes: &[u8]| URL_SAFE_NO_PAD.encode(bytes); + let header_b64 = encode(br#"{"alg":"none","typ":"JWT"}"#); + let payload_b64 = encode(&serde_json::to_vec(&payload).expect("payload should serialize")); + let signature_b64 = encode(b"sig"); + format!("{header_b64}.{payload_b64}.{signature_b64}") + } +} diff --git a/codex-rs/analytics/Cargo.toml b/codex-rs/analytics/Cargo.toml index f706814d41..918e7edc72 100644 --- a/codex-rs/analytics/Cargo.toml +++ b/codex-rs/analytics/Cargo.toml @@ -16,6 +16,7 @@ workspace = true codex-app-server-protocol = { workspace = true } codex-git-utils = { workspace = true } codex-login = { workspace = true } +codex-model-provider = { workspace = true } codex-plugin = { workspace = true } codex-protocol = { workspace = true } os_info = { workspace = true } diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index bf97da4284..352acbe1fc 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -65,6 +65,7 @@ use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::NonSteerableTurnKind; +use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -91,6 +92,7 @@ use codex_plugin::PluginTelemetryMetadata; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; +use codex_protocol::models::PermissionProfile as CorePermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookRunStatus; @@ -152,11 +154,16 @@ fn sample_thread_start_response(thread_id: &str, ephemeral: bool, model: &str) - approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, + permission_profile: Some(sample_permission_profile()), reasoning_effort: None, }, } } +fn sample_permission_profile() -> AppServerPermissionProfile { + CorePermissionProfile::Disabled.into() +} + fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { CodexAppServerClientMetadata { product_client_id: DEFAULT_ORIGINATOR.to_string(), @@ -203,6 +210,7 @@ fn sample_thread_resume_response_with_source( approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, + permission_profile: Some(sample_permission_profile()), reasoning_effort: None, }, } @@ -307,12 +315,15 @@ fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact { session_source: SessionSource::Exec, model: "gpt-5".to_string(), model_provider: "openai".to_string(), - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: CorePermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::new_read_only_policy(), + ), + permission_profile_cwd: PathBuf::from("/tmp"), reasoning_effort: None, reasoning_summary: None, service_tier: None, approval_policy: AskForApproval::OnRequest, - approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + approvals_reviewer: ApprovalsReviewer::AutoReview, sandbox_network_access: true, collaboration_mode: ModeKind::Plan, personality: None, @@ -1322,7 +1333,7 @@ fn subagent_thread_started_other_serializes_explicit_parent_thread_id() { }, )); - let payload = serde_json::to_value(&event).expect("serialize guardian subagent event"); + let payload = serde_json::to_value(&event).expect("serialize auto-review subagent event"); assert_eq!(payload["event_params"]["subagent_source"], "guardian"); assert_eq!( payload["event_params"]["parent_thread_id"], @@ -1746,7 +1757,7 @@ fn turn_event_serializes_expected_shape() { reasoning_summary: Some("detailed".to_string()), service_tier: "flex".to_string(), approval_policy: "on-request".to_string(), - approvals_reviewer: "guardian_subagent".to_string(), + approvals_reviewer: "auto_review".to_string(), sandbox_network_access: true, collaboration_mode: Some("plan"), personality: Some("pragmatic".to_string()), @@ -1807,7 +1818,7 @@ fn turn_event_serializes_expected_shape() { "reasoning_summary": "detailed", "service_tier": "flex", "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", + "approvals_reviewer": "auto_review", "sandbox_network_access": true, "collaboration_mode": "plan", "personality": "pragmatic", diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 1a4b5defe9..e145a00d1d 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -1,5 +1,6 @@ use crate::events::AppServerRpcTransport; -use crate::events::GuardianReviewEventParams; +use crate::events::GuardianReviewAnalyticsResult; +use crate::events::GuardianReviewTrackContext; use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; @@ -161,9 +162,13 @@ impl AnalyticsEventsClient { )); } - pub fn track_guardian_review(&self, input: GuardianReviewEventParams) { + pub fn track_guardian_review( + &self, + tracking: &GuardianReviewTrackContext, + result: GuardianReviewAnalyticsResult, + ) { self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::GuardianReview( - Box::new(input), + Box::new(tracking.event_params(result)), ))); } @@ -307,16 +312,9 @@ async fn send_track_events( let Some(auth) = auth_manager.auth().await else { return; }; - if !auth.is_chatgpt_auth() { + if !auth.uses_codex_backend() { return; } - let access_token = match auth.get_token() { - Ok(token) => token, - Err(_) => return, - }; - let Some(account_id) = auth.get_account_id() else { - return; - }; let base_url = base_url.trim_end_matches('/'); let url = format!("{base_url}/codex/analytics-events/events"); @@ -325,8 +323,7 @@ async fn send_track_events( let response = create_client() .post(&url) .timeout(ANALYTICS_EVENTS_TIMEOUT) - .bearer_auth(&access_token) - .header("chatgpt-account-id", &account_id) + .headers(codex_model_provider::auth_provider_from_auth(&auth).to_auth_headers()) .header("Content-Type", "application/json") .json(&payload) .send() diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index b542f8268f..98d0e6ff6b 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,3 +1,5 @@ +use std::time::Instant; + use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; use crate::facts::CompactionImplementation; @@ -16,11 +18,12 @@ use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; +use crate::now_unix_seconds; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::approvals::NetworkApprovalProtocol; -use codex_protocol::models::PermissionProfile; +use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::SandboxPermissions; use codex_protocol::protocol::GuardianAssessmentOutcome; use codex_protocol::protocol::GuardianCommandSource; @@ -30,6 +33,7 @@ use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; #[derive(Clone, Copy, Debug, Serialize)] @@ -176,17 +180,17 @@ pub enum GuardianApprovalRequestSource { pub enum GuardianReviewedAction { Shell { sandbox_permissions: SandboxPermissions, - additional_permissions: Option, + additional_permissions: Option, }, UnifiedExec { sandbox_permissions: SandboxPermissions, - additional_permissions: Option, + additional_permissions: Option, tty: bool, }, Execve { source: GuardianCommandSource, program: String, - additional_permissions: Option, + additional_permissions: Option, }, ApplyPatch {}, NetworkAccess { @@ -200,6 +204,7 @@ pub enum GuardianReviewedAction { connector_name: Option, tool_title: Option, }, + RequestPermissions {}, } #[derive(Clone, Serialize)] @@ -235,6 +240,142 @@ pub struct GuardianReviewEventParams { pub total_tokens: Option, } +pub struct GuardianReviewTrackContext { + thread_id: String, + turn_id: String, + review_id: String, + target_item_id: Option, + approval_request_source: GuardianApprovalRequestSource, + reviewed_action: GuardianReviewedAction, + review_timeout_ms: u64, + started_at: u64, + started_instant: Instant, +} + +impl GuardianReviewTrackContext { + pub fn new( + thread_id: String, + turn_id: String, + review_id: String, + target_item_id: Option, + approval_request_source: GuardianApprovalRequestSource, + reviewed_action: GuardianReviewedAction, + review_timeout_ms: u64, + ) -> Self { + Self { + thread_id, + turn_id, + review_id, + target_item_id, + approval_request_source, + reviewed_action, + review_timeout_ms, + started_at: now_unix_seconds(), + started_instant: Instant::now(), + } + } + + pub(crate) fn event_params( + &self, + result: GuardianReviewAnalyticsResult, + ) -> GuardianReviewEventParams { + GuardianReviewEventParams { + thread_id: self.thread_id.clone(), + turn_id: self.turn_id.clone(), + review_id: self.review_id.clone(), + target_item_id: self.target_item_id.clone(), + approval_request_source: self.approval_request_source, + reviewed_action: self.reviewed_action.clone(), + reviewed_action_truncated: result.reviewed_action_truncated, + decision: result.decision, + terminal_status: result.terminal_status, + failure_reason: result.failure_reason, + risk_level: result.risk_level, + user_authorization: result.user_authorization, + outcome: result.outcome, + guardian_thread_id: result.guardian_thread_id, + guardian_session_kind: result.guardian_session_kind, + guardian_model: result.guardian_model, + guardian_reasoning_effort: result.guardian_reasoning_effort, + had_prior_review_context: result.had_prior_review_context, + review_timeout_ms: self.review_timeout_ms, + // TODO(rhan-oai): plumb nested Guardian review session tool-call counts. + tool_call_count: None, + time_to_first_token_ms: result.time_to_first_token_ms, + completion_latency_ms: Some(self.started_instant.elapsed().as_millis() as u64), + started_at: self.started_at, + completed_at: Some(now_unix_seconds()), + input_tokens: result.token_usage.as_ref().map(|usage| usage.input_tokens), + cached_input_tokens: result + .token_usage + .as_ref() + .map(|usage| usage.cached_input_tokens), + output_tokens: result.token_usage.as_ref().map(|usage| usage.output_tokens), + reasoning_output_tokens: result + .token_usage + .as_ref() + .map(|usage| usage.reasoning_output_tokens), + total_tokens: result.token_usage.as_ref().map(|usage| usage.total_tokens), + } + } +} + +#[derive(Debug)] +pub struct GuardianReviewAnalyticsResult { + pub decision: GuardianReviewDecision, + pub terminal_status: GuardianReviewTerminalStatus, + pub failure_reason: Option, + pub risk_level: Option, + pub user_authorization: Option, + pub outcome: Option, + pub guardian_thread_id: Option, + pub guardian_session_kind: Option, + pub guardian_model: Option, + pub guardian_reasoning_effort: Option, + pub had_prior_review_context: Option, + pub reviewed_action_truncated: bool, + pub token_usage: Option, + pub time_to_first_token_ms: Option, +} + +impl GuardianReviewAnalyticsResult { + pub fn without_session() -> Self { + Self { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: None, + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: None, + guardian_session_kind: None, + guardian_model: None, + guardian_reasoning_effort: None, + had_prior_review_context: None, + reviewed_action_truncated: false, + token_usage: None, + time_to_first_token_ms: None, + } + } + + pub fn from_session( + guardian_thread_id: String, + guardian_session_kind: GuardianReviewSessionKind, + guardian_model: String, + guardian_reasoning_effort: Option, + had_prior_review_context: bool, + ) -> Self { + Self { + guardian_thread_id: Some(guardian_thread_id), + guardian_session_kind: Some(guardian_session_kind), + guardian_model: Some(guardian_model), + guardian_reasoning_effort, + had_prior_review_context: Some(had_prior_review_context), + ..Self::without_session() + } + } +} + #[derive(Serialize)] pub(crate) struct GuardianReviewEventPayload { pub(crate) app_server_client: CodexAppServerClientMetadata, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1d371acb1c..8ebff278c4 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -13,12 +13,12 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; +use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookSource; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; @@ -62,7 +62,8 @@ pub struct TurnResolvedConfigFact { pub session_source: SessionSource, pub model: String, pub model_provider: String, - pub sandbox_policy: SandboxPolicy, + pub permission_profile: PermissionProfile, + pub permission_profile_cwd: PathBuf, pub reasoning_effort: Option, pub reasoning_summary: Option, pub service_tier: Option, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 5c4cdfac79..ed0f1036ca 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -9,11 +9,13 @@ use std::time::UNIX_EPOCH; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use events::GuardianApprovalRequestSource; +pub use events::GuardianReviewAnalyticsResult; pub use events::GuardianReviewDecision; pub use events::GuardianReviewEventParams; pub use events::GuardianReviewFailureReason; pub use events::GuardianReviewSessionKind; pub use events::GuardianReviewTerminalStatus; +pub use events::GuardianReviewTrackContext; pub use events::GuardianReviewedAction; pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index a6ce3fc831..0445975dd6 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -61,7 +61,7 @@ use codex_login::default_client::originator; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::TokenUsage; @@ -884,7 +884,8 @@ fn codex_turn_event_params( session_source: _session_source, model, model_provider, - sandbox_policy, + permission_profile, + permission_profile_cwd, reasoning_effort, reasoning_summary, service_tier, @@ -909,7 +910,10 @@ fn codex_turn_event_params( parent_thread_id: thread_metadata.parent_thread_id.clone(), model: Some(model), model_provider, - sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + sandbox_policy: Some(sandbox_policy_mode( + &permission_profile, + permission_profile_cwd.as_path(), + )), reasoning_effort: reasoning_effort.map(|value| value.to_string()), reasoning_summary: reasoning_summary_mode(reasoning_summary), service_tier: service_tier @@ -954,12 +958,27 @@ fn codex_turn_event_params( } } -fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { - match sandbox_policy { - SandboxPolicy::DangerFullAccess => "full_access", - SandboxPolicy::ReadOnly { .. } => "read_only", - SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", - SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", +fn sandbox_policy_mode(permission_profile: &PermissionProfile, cwd: &Path) -> &'static str { + match permission_profile { + PermissionProfile::Disabled => "full_access", + PermissionProfile::External { .. } => "external_sandbox", + PermissionProfile::Managed { .. } => { + let file_system_policy = permission_profile.file_system_sandbox_policy(); + if file_system_policy.has_full_disk_write_access() { + if permission_profile.network_sandbox_policy().is_enabled() { + "full_access" + } else { + "external_sandbox" + } + } else if file_system_policy + .get_writable_roots_with_cwd(cwd) + .is_empty() + { + "read_only" + } else { + "workspace_write" + } + } } } @@ -1050,3 +1069,25 @@ pub(crate) fn normalize_path_for_skill_id( _ => resolved_path.to_string_lossy().replace('\\', "/"), } } + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::models::SandboxEnforcement; + use codex_protocol::permissions::FileSystemSandboxPolicy; + use codex_protocol::permissions::NetworkSandboxPolicy; + + #[test] + fn managed_full_disk_with_restricted_network_reports_external_sandbox() { + let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( + SandboxEnforcement::Managed, + &FileSystemSandboxPolicy::unrestricted(), + NetworkSandboxPolicy::Restricted, + ); + + assert_eq!( + sandbox_policy_mode(&permission_profile, Path::new("/")), + "external_sandbox" + ); + } +} diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 1c3de1208b..e1614c32db 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -41,10 +41,12 @@ use codex_app_server_protocol::Result as JsonRpcResult; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_arg0::Arg0DispatchPaths; +use codex_config::CloudRequirementsLoader; +use codex_config::LoaderOverrides; use codex_config::NoopThreadConfigLoader; +use codex_config::RemoteThreadConfigLoader; +use codex_config::ThreadConfigLoader; use codex_core::config::Config; -use codex_core::config_loader::CloudRequirementsLoader; -use codex_core::config_loader::LoaderOverrides; pub use codex_exec_server::EnvironmentManager; pub use codex_exec_server::EnvironmentManagerArgs; pub use codex_exec_server::ExecServerRuntimePaths; @@ -98,7 +100,7 @@ pub mod legacy_core { } pub mod plugins { - pub use codex_core::plugins::*; + pub use codex_core::plugins::PluginsManager; } pub mod review_format { @@ -357,6 +359,13 @@ pub struct InProcessClientStartArgs { pub channel_capacity: usize, } +fn configured_thread_config_loader(config: &Config) -> Arc { + match config.experimental_thread_config_endpoint.as_deref() { + Some(endpoint) => Arc::new(RemoteThreadConfigLoader::new(endpoint)), + None => Arc::new(NoopThreadConfigLoader), + } +} + impl InProcessClientStartArgs { /// Builds initialize params from caller-provided metadata. pub fn initialize_params(&self) -> InitializeParams { @@ -381,13 +390,14 @@ impl InProcessClientStartArgs { fn into_runtime_start_args(self) -> InProcessStartArgs { let initialize = self.initialize_params(); + let thread_config_loader = configured_thread_config_loader(&self.config); InProcessStartArgs { arg0_paths: self.arg0_paths, config: self.config, cli_overrides: self.cli_overrides, loader_overrides: self.loader_overrides, cloud_requirements: self.cloud_requirements, - thread_config_loader: Arc::new(NoopThreadConfigLoader), + thread_config_loader, feedback: self.feedback, log_db: self.log_db, environment_manager: self.environment_manager, @@ -2013,6 +2023,42 @@ mod tests { ); } + #[tokio::test] + async fn runtime_start_args_use_remote_thread_config_loader_when_configured() { + let mut config = build_test_config().await; + config.experimental_thread_config_endpoint = Some("not-a-valid-endpoint".to_string()); + + let runtime_args = InProcessClientStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config: Arc::new(config), + cli_overrides: Vec::new(), + loader_overrides: LoaderOverrides::default(), + cloud_requirements: CloudRequirementsLoader::default(), + feedback: CodexFeedback::new(), + log_db: None, + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), + config_warnings: Vec::new(), + session_source: SessionSource::Exec, + enable_codex_api_key_env: false, + client_name: "codex-app-server-client-test".to_string(), + client_version: "0.0.0-test".to_string(), + experimental_api: true, + opt_out_notification_methods: Vec::new(), + channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + } + .into_runtime_start_args(); + + let err = runtime_args + .thread_config_loader + .load(Default::default()) + .await + .expect_err("configured remote loader should try to connect"); + assert_eq!( + err.code(), + codex_config::ThreadConfigLoadErrorCode::RequestFailed + ); + } + #[tokio::test] async fn shutdown_completes_promptly_without_retained_managers() { let client = start_test_client(SessionSource::Cli).await; diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index 51015f8e7b..4fb54184aa 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -20,7 +20,6 @@ use crate::RequestResult; use crate::SHUTDOWN_TIMEOUT; use crate::TypedRequestError; use crate::request_method_name; -use crate::server_notification_requires_delivery; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientNotification; use codex_app_server_protocol::ClientRequest; @@ -126,7 +125,7 @@ enum RemoteClientCommand { pub struct RemoteAppServerClient { command_tx: mpsc::Sender, - event_rx: mpsc::Receiver, + event_rx: mpsc::UnboundedReceiver, pending_events: VecDeque, worker_handle: tokio::task::JoinHandle<()>, } @@ -195,11 +194,10 @@ impl RemoteAppServerClient { .await?; let (command_tx, mut command_rx) = mpsc::channel::(channel_capacity); - let (event_tx, event_rx) = mpsc::channel::(channel_capacity); + let (event_tx, event_rx) = mpsc::unbounded_channel::(); let worker_handle = tokio::spawn(async move { let mut pending_requests = HashMap::>>::new(); - let mut skipped_events = 0usize; loop { tokio::select! { command = command_rx.recv() => { @@ -231,15 +229,12 @@ impl RemoteAppServerClient { } let _ = deliver_event( &event_tx, - &mut skipped_events, AppServerEvent::Disconnected { message: format!( "remote app server at `{websocket_url}` write failed: {err_message}" ), }, - &mut stream, - ) - .await; + ); break; } } @@ -316,11 +311,8 @@ impl RemoteAppServerClient { app_server_event_from_notification(notification) && let Err(err) = deliver_event( &event_tx, - &mut skipped_events, event, - &mut stream, ) - .await { warn!(%err, "failed to deliver remote app-server event"); break; @@ -333,11 +325,8 @@ impl RemoteAppServerClient { Ok(request) => { if let Err(err) = deliver_event( &event_tx, - &mut skipped_events, AppServerEvent::ServerRequest(request), - &mut stream, ) - .await { warn!(%err, "failed to deliver remote app-server server request"); break; @@ -364,15 +353,12 @@ impl RemoteAppServerClient { let err_message = reject_err.to_string(); let _ = deliver_event( &event_tx, - &mut skipped_events, AppServerEvent::Disconnected { message: format!( "remote app server at `{websocket_url}` write failed: {err_message}" ), }, - &mut stream, - ) - .await; + ); break; } } @@ -381,15 +367,12 @@ impl RemoteAppServerClient { Err(err) => { let _ = deliver_event( &event_tx, - &mut skipped_events, AppServerEvent::Disconnected { message: format!( "remote app server at `{websocket_url}` sent invalid JSON-RPC: {err}" ), }, - &mut stream, - ) - .await; + ); break; } } @@ -402,15 +385,12 @@ impl RemoteAppServerClient { .unwrap_or_else(|| "connection closed".to_string()); let _ = deliver_event( &event_tx, - &mut skipped_events, AppServerEvent::Disconnected { message: format!( "remote app server at `{websocket_url}` disconnected: {reason}" ), }, - &mut stream, - ) - .await; + ); break; } Some(Ok(Message::Binary(_))) @@ -420,29 +400,23 @@ impl RemoteAppServerClient { Some(Err(err)) => { let _ = deliver_event( &event_tx, - &mut skipped_events, AppServerEvent::Disconnected { message: format!( "remote app server at `{websocket_url}` transport failed: {err}" ), }, - &mut stream, - ) - .await; + ); break; } None => { let _ = deliver_event( &event_tx, - &mut skipped_events, AppServerEvent::Disconnected { message: format!( "remote app server at `{websocket_url}` closed the connection" ), }, - &mut stream, - ) - .await; + ); break; } } @@ -612,14 +586,9 @@ impl RemoteAppServerClient { .send(RemoteClientCommand::Shutdown { response_tx }) .await .is_ok() - && let Ok(command_result) = timeout(SHUTDOWN_TIMEOUT, response_rx).await + && let Ok(Ok(close_result)) = timeout(SHUTDOWN_TIMEOUT, response_rx).await { - command_result.map_err(|_| { - IoError::new( - ErrorKind::BrokenPipe, - "remote app-server shutdown channel is closed", - ) - })??; + close_result?; } if let Err(_elapsed) = timeout(SHUTDOWN_TIMEOUT, &mut worker_handle).await { @@ -806,100 +775,16 @@ fn app_server_event_from_notification(notification: JSONRPCNotification) -> Opti } } -async fn deliver_event( - event_tx: &mpsc::Sender, - skipped_events: &mut usize, +fn deliver_event( + event_tx: &mpsc::UnboundedSender, event: AppServerEvent, - stream: &mut WebSocketStream>, ) -> IoResult<()> { - if *skipped_events > 0 { - if event_requires_delivery(&event) { - if event_tx - .send(AppServerEvent::Lagged { - skipped: *skipped_events, - }) - .await - .is_err() - { - return Err(IoError::new( - ErrorKind::BrokenPipe, - "remote app-server event consumer channel is closed", - )); - } - *skipped_events = 0; - } else { - match event_tx.try_send(AppServerEvent::Lagged { - skipped: *skipped_events, - }) { - Ok(()) => *skipped_events = 0, - Err(mpsc::error::TrySendError::Full(_)) => { - *skipped_events = (*skipped_events).saturating_add(1); - reject_if_server_request_dropped(stream, &event).await?; - return Ok(()); - } - Err(mpsc::error::TrySendError::Closed(_)) => { - return Err(IoError::new( - ErrorKind::BrokenPipe, - "remote app-server event consumer channel is closed", - )); - } - } - } - } - - if event_requires_delivery(&event) { - event_tx.send(event).await.map_err(|_| { - IoError::new( - ErrorKind::BrokenPipe, - "remote app-server event consumer channel is closed", - ) - })?; - return Ok(()); - } - - match event_tx.try_send(event) { - Ok(()) => Ok(()), - Err(mpsc::error::TrySendError::Full(event)) => { - *skipped_events = (*skipped_events).saturating_add(1); - reject_if_server_request_dropped(stream, &event).await - } - Err(mpsc::error::TrySendError::Closed(_)) => Err(IoError::new( + event_tx.send(event).map_err(|_| { + IoError::new( ErrorKind::BrokenPipe, "remote app-server event consumer channel is closed", - )), - } -} - -async fn reject_if_server_request_dropped( - stream: &mut WebSocketStream>, - event: &AppServerEvent, -) -> IoResult<()> { - let AppServerEvent::ServerRequest(request) = event else { - return Ok(()); - }; - write_jsonrpc_message( - stream, - JSONRPCMessage::Error(JSONRPCError { - error: JSONRPCErrorError { - code: -32001, - message: "remote app-server event queue is full".to_string(), - data: None, - }, - id: request.id().clone(), - }), - "", - ) - .await -} - -fn event_requires_delivery(event: &AppServerEvent) -> bool { - match event { - AppServerEvent::ServerNotification(notification) => { - server_notification_requires_delivery(notification) - } - AppServerEvent::Disconnected { .. } => true, - AppServerEvent::Lagged { .. } | AppServerEvent::ServerRequest(_) => false, - } + ) + }) } fn request_id_from_client_request(request: &ClientRequest) -> RequestId { @@ -945,40 +830,27 @@ async fn write_jsonrpc_message( )) }) } - #[cfg(test)] mod tests { use super::*; - #[test] - fn event_requires_delivery_marks_transcript_and_disconnect_events() { - assert!(event_requires_delivery( - &AppServerEvent::ServerNotification(ServerNotification::AgentMessageDelta( - codex_app_server_protocol::AgentMessageDeltaNotification { - thread_id: "thread".to_string(), - turn_id: "turn".to_string(), - item_id: "item".to_string(), - delta: "hello".to_string(), - }, - ),) - )); - assert!(event_requires_delivery( - &AppServerEvent::ServerNotification(ServerNotification::ItemCompleted( - codex_app_server_protocol::ItemCompletedNotification { - thread_id: "thread".to_string(), - turn_id: "turn".to_string(), - item: codex_app_server_protocol::ThreadItem::Plan { - id: "item".to_string(), - text: "step".to_string(), - }, - } - ),) - )); - assert!(event_requires_delivery(&AppServerEvent::Disconnected { - message: "closed".to_string(), - })); - assert!(!event_requires_delivery(&AppServerEvent::Lagged { - skipped: 1 - })); + #[tokio::test] + async fn shutdown_tolerates_worker_exit_after_command_is_queued() { + let (command_tx, mut command_rx) = mpsc::channel(1); + let (_event_tx, event_rx) = mpsc::unbounded_channel::(); + let worker_handle = tokio::spawn(async move { + let _ = command_rx.recv().await; + }); + let client = RemoteAppServerClient { + command_tx, + event_rx, + pending_events: VecDeque::new(), + worker_handle, + }; + + client + .shutdown() + .await + .expect("shutdown should complete when worker exits first"); } } diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 352cf02113..079a8a2d76 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -13,9 +13,10 @@ "type": "string" }, "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "type": "string" @@ -217,6 +218,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Optional full permissions profile for this command.\n\nDefaults to the user's configured permissions when omitted. Cannot be combined with `sandboxPolicy`." + }, "processId": { "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ @@ -233,7 +245,7 @@ "type": "null" } ], - "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted. Cannot be combined with `permissionProfile`." }, "size": { "anyOf": [ @@ -897,6 +909,202 @@ ], "type": "object" }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "FsCopyParams": { "description": "Copy a file or directory tree on the host filesystem.", "properties": { @@ -1192,38 +1400,6 @@ }, "type": "object" }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { - "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] - }, - "preexisting_untracked_dirs": { - "items": { - "type": "string" - }, - "type": "array" - }, - "preexisting_untracked_files": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "id", - "preexisting_untracked_dirs", - "preexisting_untracked_files" - ], - "type": "object" - }, "ImageDetail": { "enum": [ "auto", @@ -1498,6 +1674,17 @@ ], "type": "object" }, + "MarketplaceUpgradeParams": { + "properties": { + "marketplaceName": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "McpResourceReadParams": { "properties": { "server": { @@ -1657,6 +1844,135 @@ ], "type": "string" }, + "PermissionProfile": { + "oneOf": [ + { + "description": "Codex owns sandbox construction for this profile.", + "properties": { + "fileSystem": { + "$ref": "#/definitions/PermissionProfileFileSystemPermissions" + }, + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "managed" + ], + "title": "ManagedPermissionProfileType", + "type": "string" + } + }, + "required": [ + "fileSystem", + "network", + "type" + ], + "title": "ManagedPermissionProfile", + "type": "object" + }, + { + "description": "Do not apply an outer sandbox.", + "properties": { + "type": { + "enum": [ + "disabled" + ], + "title": "DisabledPermissionProfileType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DisabledPermissionProfile", + "type": "object" + }, + { + "description": "Filesystem isolation is enforced by an external caller.", + "properties": { + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "external" + ], + "title": "ExternalPermissionProfileType", + "type": "string" + } + }, + "required": [ + "network", + "type" + ], + "title": "ExternalPermissionProfile", + "type": "object" + } + ] + }, + "PermissionProfileFileSystemPermissions": { + "oneOf": [ + { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "entries", + "type" + ], + "title": "RestrictedPermissionProfileFileSystemPermissions", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "type": "object" + } + ] + }, + "PermissionProfileNetworkPermissions": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, "Personality": { "enum": [ "none", @@ -1763,53 +2079,6 @@ ], "type": "object" }, - "ReadOnlyAccess": { - "oneOf": [ - { - "properties": { - "includePlatformDefaults": { - "default": true, - "type": "boolean" - }, - "readableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RestrictedReadOnlyAccess", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "fullAccess" - ], - "title": "FullAccessReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "FullAccessReadOnlyAccess", - "type": "object" - } - ] - }, "RealtimeOutputModality": { "enum": [ "text", @@ -1976,12 +2245,6 @@ }, "type": "array" }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, "id": { "type": [ "string", @@ -2381,26 +2644,6 @@ "title": "ImageGenerationCallResponseItem", "type": "object" }, - { - "properties": { - "ghost_commit": { - "$ref": "#/definitions/GhostCommit" - }, - "type": { - "enum": [ - "ghost_snapshot" - ], - "title": "GhostSnapshotResponseItemType", - "type": "string" - } - }, - "required": [ - "ghost_commit", - "type" - ], - "title": "GhostSnapshotResponseItem", - "type": "object" - }, { "properties": { "encrypted_content": { @@ -2693,16 +2936,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -2759,16 +2992,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" @@ -2942,6 +3165,21 @@ ], "type": "object" }, + "ThreadApproveGuardianDeniedActionParams": { + "properties": { + "event": { + "description": "Serialized `codex_protocol::protocol::GuardianAssessmentEvent`." + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "event", + "threadId" + ], + "type": "object" + }, "ThreadArchiveParams": { "properties": { "threadId": { @@ -3016,6 +3254,10 @@ "ephemeral": { "type": "boolean" }, + "excludeTurns": { + "description": "When true, return only thread metadata and live fork state without populating `thread.turns`. This is useful when the client plans to call `thread/turns/list` immediately after forking.", + "type": "boolean" + }, "model": { "description": "Configuration overrides for the forked thread, if any.", "type": [ @@ -3029,6 +3271,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Full permissions override for the forked thread. Cannot be combined with `sandbox`." + }, "sandbox": { "anyOf": [ { @@ -3065,6 +3318,15 @@ ], "type": "object" }, + "ThreadGoalStatus": { + "enum": [ + "active", + "paused", + "budgetLimited", + "complete" + ], + "type": "string" + }, "ThreadInjectItemsParams": { "properties": { "items": { @@ -3082,6 +3344,19 @@ ], "type": "object" }, + "ThreadListCwdFilter": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, "ThreadListParams": { "properties": { "archived": { @@ -3099,11 +3374,15 @@ ] }, "cwd": { - "description": "Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned.", - "type": [ - "string", - "null" - ] + "anyOf": [ + { + "$ref": "#/definitions/ThreadListCwdFilter" + }, + { + "type": "null" + } + ], + "description": "Optional cwd filter or filters; when set, only threads whose session cwd exactly matches one of these paths are returned." }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", @@ -3162,6 +3441,10 @@ "array", "null" ] + }, + "useStateDbOnly": { + "description": "If true, return from the state DB without scanning JSONL rollouts to repair thread metadata. Omitted or false preserves scan-and-repair behavior.", + "type": "boolean" } }, "type": "object" @@ -3387,6 +3670,10 @@ "null" ] }, + "excludeTurns": { + "description": "When true, return only thread metadata and live-resume state without populating `thread.turns`. This is useful when the client plans to call `thread/turns/list` immediately after resuming.", + "type": "boolean" + }, "model": { "description": "Configuration overrides for the resumed thread, if any.", "type": [ @@ -3400,6 +3687,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Full permissions override for the resumed thread. Cannot be combined with `sandbox`." + }, "personality": { "anyOf": [ { @@ -3583,6 +3881,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Full permissions override for this thread. Cannot be combined with `sandbox`." + }, "personality": { "anyOf": [ { @@ -3706,6 +4015,21 @@ ], "type": "object" }, + "TurnEnvironmentParams": { + "properties": { + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "environmentId": { + "type": "string" + } + }, + "required": [ + "cwd", + "environmentId" + ], + "type": "object" + }, "TurnInterruptParams": { "properties": { "threadId": { @@ -3779,6 +4103,17 @@ "outputSchema": { "description": "Optional JSON Schema used to constrain the final assistant message for this turn." }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Override the full permissions profile for this turn and subsequent turns. Cannot be combined with `sandboxPolicy`." + }, "personality": { "anyOf": [ { @@ -4279,6 +4614,30 @@ "title": "Thread/shellCommandRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/approveGuardianDeniedAction" + ], + "title": "Thread/approveGuardianDeniedActionRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadApproveGuardianDeniedActionParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/approveGuardianDeniedActionRequest", + "type": "object" + }, { "properties": { "id": { @@ -4496,6 +4855,30 @@ "title": "Marketplace/removeRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "marketplace/upgrade" + ], + "title": "Marketplace/upgradeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/MarketplaceUpgradeParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Marketplace/upgradeRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json index 3968da2a39..ce587a7f10 100644 --- a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json @@ -25,6 +25,7 @@ ] }, "read": { + "description": "This will be removed in favor of `entries`.", "items": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -34,6 +35,7 @@ ] }, "write": { + "description": "This will be removed in favor of `entries`.", "items": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -76,7 +78,8 @@ { "type": "null" } - ] + ], + "description": "Partial overlay used for per-command permission requests." } }, "type": "object" @@ -389,21 +392,6 @@ "title": "MinimalFileSystemSpecialPath", "type": "object" }, - { - "properties": { - "kind": { - "enum": [ - "current_working_directory" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "CurrentWorkingDirectoryFileSystemSpecialPath", - "type": "object" - }, { "properties": { "kind": { diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json index c2e26c5532..adb50dee43 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json @@ -25,6 +25,7 @@ ] }, "read": { + "description": "This will be removed in favor of `entries`.", "items": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -34,6 +35,7 @@ ] }, "write": { + "description": "This will be removed in favor of `entries`.", "items": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -175,21 +177,6 @@ "title": "MinimalFileSystemSpecialPath", "type": "object" }, - { - "properties": { - "kind": { - "enum": [ - "current_working_directory" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "CurrentWorkingDirectoryFileSystemSpecialPath", - "type": "object" - }, { "properties": { "kind": { diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json index 11cce8c88c..3e775a3da9 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json @@ -25,6 +25,7 @@ ] }, "read": { + "description": "This will be removed in favor of `entries`.", "items": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -34,6 +35,7 @@ ] }, "write": { + "description": "This will be removed in favor of `entries`.", "items": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -175,21 +177,6 @@ "title": "MinimalFileSystemSpecialPath", "type": "object" }, - { - "properties": { - "kind": { - "enum": [ - "current_working_directory" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "CurrentWorkingDirectoryFileSystemSpecialPath", - "type": "object" - }, { "properties": { "kind": { @@ -311,6 +298,13 @@ } ], "default": "turn" + }, + "strictAutoReview": { + "description": "Review every subsequent command in this turn before normal sandboxed execution.", + "type": [ + "boolean", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 1443444683..aa5c944d9c 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -84,6 +84,7 @@ ] }, "read": { + "description": "This will be removed in favor of `entries`.", "items": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -93,6 +94,7 @@ ] }, "write": { + "description": "This will be removed in favor of `entries`.", "items": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -436,6 +438,13 @@ "chatgptAuthTokens" ], "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a registered Agent Identity.", + "enum": [ + "agentIdentity" + ], + "type": "string" } ] }, @@ -473,6 +482,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", @@ -1189,21 +1199,6 @@ "title": "MinimalFileSystemSpecialPath", "type": "object" }, - { - "properties": { - "kind": { - "enum": [ - "current_working_directory" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "CurrentWorkingDirectoryFileSystemSpecialPath", - "type": "object" - }, { "properties": { "kind": { @@ -1697,6 +1692,23 @@ ], "type": "string" }, + "GuardianWarningNotification": { + "properties": { + "message": { + "description": "Concise guardian warning message for the user.", + "type": "string" + }, + "threadId": { + "description": "Thread target for the guardian warning.", + "type": "string" + } + }, + "required": [ + "message", + "threadId" + ], + "type": "object" + }, "HookCompletedNotification": { "properties": { "run": { @@ -2236,6 +2248,34 @@ ], "type": "object" }, + "ModelVerification": { + "enum": [ + "trustedAccessForCyber" + ], + "type": "string" + }, + "ModelVerificationNotification": { + "properties": { + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "verifications": { + "items": { + "$ref": "#/definitions/ModelVerification" + }, + "type": "array" + } + }, + "required": [ + "threadId", + "turnId", + "verifications" + ], + "type": "object" + }, "NetworkApprovalProtocol": { "enum": [ "http", @@ -2973,6 +3013,93 @@ ], "type": "object" }, + "ThreadGoal": { + "properties": { + "createdAt": { + "format": "int64", + "type": "integer" + }, + "objective": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/ThreadGoalStatus" + }, + "threadId": { + "type": "string" + }, + "timeUsedSeconds": { + "format": "int64", + "type": "integer" + }, + "tokenBudget": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "tokensUsed": { + "format": "int64", + "type": "integer" + }, + "updatedAt": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "createdAt", + "objective", + "status", + "threadId", + "timeUsedSeconds", + "tokensUsed", + "updatedAt" + ], + "type": "object" + }, + "ThreadGoalClearedNotification": { + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "type": "object" + }, + "ThreadGoalStatus": { + "enum": [ + "active", + "paused", + "budgetLimited", + "complete" + ], + "type": "string" + }, + "ThreadGoalUpdatedNotification": { + "properties": { + "goal": { + "$ref": "#/definitions/ThreadGoal" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "goal", + "threadId" + ], + "type": "object" + }, "ThreadId": { "type": "string" }, @@ -4672,6 +4799,46 @@ "title": "Thread/name/updatedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/goal/updated" + ], + "title": "Thread/goal/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadGoalUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/goal/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/goal/cleared" + ], + "title": "Thread/goal/clearedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadGoalClearedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/goal/clearedNotification", + "type": "object" + }, { "properties": { "method": { @@ -5315,6 +5482,26 @@ "title": "Model/reroutedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "model/verification" + ], + "title": "Model/verificationNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ModelVerificationNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/verificationNotification", + "type": "object" + }, { "properties": { "method": { @@ -5335,6 +5522,26 @@ "title": "WarningNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "guardianWarning" + ], + "title": "GuardianWarningNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/GuardianWarningNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "GuardianWarningNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 016dbe3415..51cab50810 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -25,6 +25,7 @@ ] }, "read": { + "description": "This will be removed in favor of `entries`.", "items": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -34,6 +35,7 @@ ] }, "write": { + "description": "This will be removed in favor of `entries`.", "items": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -76,7 +78,8 @@ { "type": "null" } - ] + ], + "description": "Partial overlay used for per-command permission requests." } }, "type": "object" @@ -728,21 +731,6 @@ "title": "MinimalFileSystemSpecialPath", "type": "object" }, - { - "properties": { - "kind": { - "enum": [ - "current_working_directory" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "CurrentWorkingDirectoryFileSystemSpecialPath", - "type": "object" - }, { "properties": { "kind": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index d1518ce28f..7e34967470 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -25,7 +25,8 @@ { "type": "null" } - ] + ], + "description": "Partial overlay used for per-command permission requests." } }, "type": "object" @@ -448,6 +449,30 @@ "title": "Thread/shellCommandRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "thread/approveGuardianDeniedAction" + ], + "title": "Thread/approveGuardianDeniedActionRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadApproveGuardianDeniedActionParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/approveGuardianDeniedActionRequest", + "type": "object" + }, { "properties": { "id": { @@ -665,6 +690,30 @@ "title": "Marketplace/removeRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "marketplace/upgrade" + ], + "title": "Marketplace/upgradeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/MarketplaceUpgradeParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Marketplace/upgradeRequest", + "type": "object" + }, { "properties": { "id": { @@ -3477,6 +3526,13 @@ } ], "default": "turn" + }, + "strictAutoReview": { + "description": "Review every subsequent command in this turn before normal sandboxed execution.", + "type": [ + "boolean", + "null" + ] } }, "required": [ @@ -3750,6 +3806,46 @@ "title": "Thread/name/updatedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/goal/updated" + ], + "title": "Thread/goal/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadGoalUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/goal/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/goal/cleared" + ], + "title": "Thread/goal/clearedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadGoalClearedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/goal/clearedNotification", + "type": "object" + }, { "properties": { "method": { @@ -4393,6 +4489,26 @@ "title": "Model/reroutedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "model/verification" + ], + "title": "Model/verificationNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ModelVerificationNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/verificationNotification", + "type": "object" + }, { "properties": { "method": { @@ -4413,6 +4529,26 @@ "title": "WarningNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "guardianWarning" + ], + "title": "GuardianWarningNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/GuardianWarningNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "GuardianWarningNotification", + "type": "object" + }, { "properties": { "method": { @@ -5125,6 +5261,22 @@ ], "title": "ChatgptAccount", "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "amazonBedrock" + ], + "title": "AmazonBedrockAccountType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "AmazonBedrockAccount", + "type": "object" } ] }, @@ -5227,6 +5379,7 @@ ] }, "read": { + "description": "This will be removed in favor of `entries`.", "items": { "$ref": "#/definitions/v2/AbsolutePathBuf" }, @@ -5236,6 +5389,7 @@ ] }, "write": { + "description": "This will be removed in favor of `entries`.", "items": { "$ref": "#/definitions/v2/AbsolutePathBuf" }, @@ -5684,9 +5838,10 @@ "type": "object" }, "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "type": "string" @@ -5856,6 +6011,13 @@ "chatgptAuthTokens" ], "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a registered Agent Identity.", + "enum": [ + "agentIdentity" + ], + "type": "string" } ] }, @@ -5926,6 +6088,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", @@ -6373,6 +6536,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Optional full permissions profile for this command.\n\nDefaults to the user's configured permissions when omitted. Cannot be combined with `sandboxPolicy`." + }, "processId": { "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ @@ -6389,7 +6563,7 @@ "type": "null" } ], - "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted. Cannot be combined with `permissionProfile`." }, "size": { "anyOf": [ @@ -7266,6 +7440,100 @@ "title": "ConfigWriteResponse", "type": "object" }, + "ConfiguredHookHandler": { + "oneOf": [ + { + "properties": { + "async": { + "type": "boolean" + }, + "command": { + "type": "string" + }, + "statusMessage": { + "type": [ + "string", + "null" + ] + }, + "timeoutSec": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "command" + ], + "title": "CommandConfiguredHookHandlerType", + "type": "string" + } + }, + "required": [ + "async", + "command", + "type" + ], + "title": "CommandConfiguredHookHandler", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "prompt" + ], + "title": "PromptConfiguredHookHandlerType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "PromptConfiguredHookHandler", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "agent" + ], + "title": "AgentConfiguredHookHandlerType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "AgentConfiguredHookHandler", + "type": "object" + } + ] + }, + "ConfiguredHookMatcherGroup": { + "properties": { + "hooks": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookHandler" + }, + "type": "array" + }, + "matcher": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "hooks" + ], + "type": "object" + }, "ContentItem": { "oneOf": [ { @@ -8305,21 +8573,6 @@ "title": "MinimalFileSystemSpecialPath", "type": "object" }, - { - "properties": { - "kind": { - "enum": [ - "current_working_directory" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "CurrentWorkingDirectoryFileSystemSpecialPath", - "type": "object" - }, { "properties": { "kind": { @@ -8930,38 +9183,6 @@ "title": "GetAccountResponse", "type": "object" }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { - "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] - }, - "preexisting_untracked_dirs": { - "items": { - "type": "string" - }, - "type": "array" - }, - "preexisting_untracked_files": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "id", - "preexisting_untracked_dirs", - "preexisting_untracked_files" - ], - "type": "object" - }, "GitInfo": { "properties": { "branch": { @@ -9257,6 +9478,25 @@ ], "type": "string" }, + "GuardianWarningNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "message": { + "description": "Concise guardian warning message for the user.", + "type": "string" + }, + "threadId": { + "description": "Thread target for the guardian warning.", + "type": "string" + } + }, + "required": [ + "message", + "threadId" + ], + "title": "GuardianWarningNotification", + "type": "object" + }, "HookCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -9940,6 +10180,67 @@ "title": "LogoutAccountResponse", "type": "object" }, + "ManagedHooksRequirements": { + "properties": { + "PermissionRequest": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "PostToolUse": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "PreToolUse": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "SessionStart": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "Stop": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "UserPromptSubmit": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "managedDir": { + "type": [ + "string", + "null" + ] + }, + "windowsManagedDir": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "PermissionRequest", + "PostToolUse", + "PreToolUse", + "SessionStart", + "Stop", + "UserPromptSubmit" + ], + "type": "object" + }, "MarketplaceAddParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -10051,6 +10352,64 @@ "title": "MarketplaceRemoveResponse", "type": "object" }, + "MarketplaceUpgradeErrorInfo": { + "properties": { + "marketplaceName": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "marketplaceName", + "message" + ], + "type": "object" + }, + "MarketplaceUpgradeParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "marketplaceName": { + "type": [ + "string", + "null" + ] + } + }, + "title": "MarketplaceUpgradeParams", + "type": "object" + }, + "MarketplaceUpgradeResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "errors": { + "items": { + "$ref": "#/definitions/v2/MarketplaceUpgradeErrorInfo" + }, + "type": "array" + }, + "selectedMarketplaces": { + "items": { + "type": "string" + }, + "type": "array" + }, + "upgradedRoots": { + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": [ + "errors", + "selectedMarketplaces", + "upgradedRoots" + ], + "title": "MarketplaceUpgradeResponse", + "type": "object" + }, "McpAuthStatus": { "enum": [ "unsupported", @@ -10660,6 +11019,36 @@ ], "type": "object" }, + "ModelVerification": { + "enum": [ + "trustedAccessForCyber" + ], + "type": "string" + }, + "ModelVerificationNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "verifications": { + "items": { + "$ref": "#/definitions/v2/ModelVerification" + }, + "type": "array" + } + }, + "required": [ + "threadId", + "turnId", + "verifications" + ], + "title": "ModelVerificationNotification", + "type": "object" + }, "NetworkAccess": { "enum": [ "restricted", @@ -10889,6 +11278,135 @@ } ] }, + "PermissionProfile": { + "oneOf": [ + { + "description": "Codex owns sandbox construction for this profile.", + "properties": { + "fileSystem": { + "$ref": "#/definitions/v2/PermissionProfileFileSystemPermissions" + }, + "network": { + "$ref": "#/definitions/v2/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "managed" + ], + "title": "ManagedPermissionProfileType", + "type": "string" + } + }, + "required": [ + "fileSystem", + "network", + "type" + ], + "title": "ManagedPermissionProfile", + "type": "object" + }, + { + "description": "Do not apply an outer sandbox.", + "properties": { + "type": { + "enum": [ + "disabled" + ], + "title": "DisabledPermissionProfileType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DisabledPermissionProfile", + "type": "object" + }, + { + "description": "Filesystem isolation is enforced by an external caller.", + "properties": { + "network": { + "$ref": "#/definitions/v2/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "external" + ], + "title": "ExternalPermissionProfileType", + "type": "string" + } + }, + "required": [ + "network", + "type" + ], + "title": "ExternalPermissionProfile", + "type": "object" + } + ] + }, + "PermissionProfileFileSystemPermissions": { + "oneOf": [ + { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/v2/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "entries", + "type" + ], + "title": "RestrictedPermissionProfileFileSystemPermissions", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "type": "object" + } + ] + }, + "PermissionProfileNetworkPermissions": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, "Personality": { "enum": [ "none", @@ -10965,7 +11483,14 @@ "type": "string" }, "marketplacePath": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "mcpServers": { "items": { @@ -10986,7 +11511,6 @@ "required": [ "apps", "marketplaceName", - "marketplacePath", "mcpServers", "skills", "summary" @@ -11697,53 +12221,6 @@ "title": "RawResponseItemCompletedNotification", "type": "object" }, - "ReadOnlyAccess": { - "oneOf": [ - { - "properties": { - "includePlatformDefaults": { - "default": true, - "type": "boolean" - }, - "readableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - "type": "array" - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RestrictedReadOnlyAccess", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "fullAccess" - ], - "title": "FullAccessReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "FullAccessReadOnlyAccess", - "type": "object" - } - ] - }, "RealtimeConversationVersion": { "enum": [ "v1", @@ -12215,12 +12692,6 @@ }, "type": "array" }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, "id": { "type": [ "string", @@ -12620,26 +13091,6 @@ "title": "ImageGenerationCallResponseItem", "type": "object" }, - { - "properties": { - "ghost_commit": { - "$ref": "#/definitions/v2/GhostCommit" - }, - "type": { - "enum": [ - "ghost_snapshot" - ], - "title": "GhostSnapshotResponseItemType", - "type": "string" - } - }, - "required": [ - "ghost_commit", - "type" - ], - "title": "GhostSnapshotResponseItem", - "type": "object" - }, { "properties": { "encrypted_content": { @@ -12952,16 +13403,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/v2/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -13018,16 +13459,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/v2/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" @@ -13356,7 +13787,14 @@ "type": "string" }, "path": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "shortDescription": { "type": [ @@ -13368,8 +13806,7 @@ "required": [ "description", "enabled", - "name", - "path" + "name" ], "type": "object" }, @@ -13852,6 +14289,28 @@ ], "type": "string" }, + "ThreadApproveGuardianDeniedActionParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "event": { + "description": "Serialized `codex_protocol::protocol::GuardianAssessmentEvent`." + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "event", + "threadId" + ], + "title": "ThreadApproveGuardianDeniedActionParams", + "type": "object" + }, + "ThreadApproveGuardianDeniedActionResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadApproveGuardianDeniedActionResponse", + "type": "object" + }, "ThreadArchiveParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -13967,6 +14426,10 @@ "ephemeral": { "type": "boolean" }, + "excludeTurns": { + "description": "When true, return only thread metadata and live fork state without populating `thread.turns`. This is useful when the client plans to call `thread/turns/list` immediately after forking.", + "type": "boolean" + }, "model": { "description": "Configuration overrides for the forked thread, if any.", "type": [ @@ -13980,6 +14443,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Full permissions override for the forked thread. Cannot be combined with `sandbox`." + }, "sandbox": { "anyOf": [ { @@ -14048,6 +14522,18 @@ "modelProvider": { "type": "string" }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PermissionProfile" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Canonical active permissions view for this thread." + }, "reasoningEffort": { "anyOf": [ { @@ -14059,7 +14545,12 @@ ] }, "sandbox": { - "$ref": "#/definitions/v2/SandboxPolicy" + "allOf": [ + { + "$ref": "#/definitions/v2/SandboxPolicy" + } + ], + "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." }, "serviceTier": { "anyOf": [ @@ -14087,6 +14578,97 @@ "title": "ThreadForkResponse", "type": "object" }, + "ThreadGoal": { + "properties": { + "createdAt": { + "format": "int64", + "type": "integer" + }, + "objective": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/v2/ThreadGoalStatus" + }, + "threadId": { + "type": "string" + }, + "timeUsedSeconds": { + "format": "int64", + "type": "integer" + }, + "tokenBudget": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "tokensUsed": { + "format": "int64", + "type": "integer" + }, + "updatedAt": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "createdAt", + "objective", + "status", + "threadId", + "timeUsedSeconds", + "tokensUsed", + "updatedAt" + ], + "type": "object" + }, + "ThreadGoalClearedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadGoalClearedNotification", + "type": "object" + }, + "ThreadGoalStatus": { + "enum": [ + "active", + "paused", + "budgetLimited", + "complete" + ], + "type": "string" + }, + "ThreadGoalUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "goal": { + "$ref": "#/definitions/v2/ThreadGoal" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "goal", + "threadId" + ], + "title": "ThreadGoalUpdatedNotification", + "type": "object" + }, "ThreadId": { "type": "string" }, @@ -14774,6 +15356,19 @@ } ] }, + "ThreadListCwdFilter": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, "ThreadListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14792,11 +15387,15 @@ ] }, "cwd": { - "description": "Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned.", - "type": [ - "string", - "null" - ] + "anyOf": [ + { + "$ref": "#/definitions/v2/ThreadListCwdFilter" + }, + { + "type": "null" + } + ], + "description": "Optional cwd filter or filters; when set, only threads whose session cwd exactly matches one of these paths are returned." }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", @@ -14855,6 +15454,10 @@ "array", "null" ] + }, + "useStateDbOnly": { + "description": "If true, return from the state DB without scanning JSONL rollouts to repair thread metadata. Omitted or false preserves scan-and-repair behavior.", + "type": "boolean" } }, "title": "ThreadListParams", @@ -15347,6 +15950,10 @@ "null" ] }, + "excludeTurns": { + "description": "When true, return only thread metadata and live-resume state without populating `thread.turns`. This is useful when the client plans to call `thread/turns/list` immediately after resuming.", + "type": "boolean" + }, "model": { "description": "Configuration overrides for the resumed thread, if any.", "type": [ @@ -15360,6 +15967,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Full permissions override for the resumed thread. Cannot be combined with `sandbox`." + }, "personality": { "anyOf": [ { @@ -15438,6 +16056,18 @@ "modelProvider": { "type": "string" }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PermissionProfile" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Canonical active permissions view for this thread." + }, "reasoningEffort": { "anyOf": [ { @@ -15449,7 +16079,12 @@ ] }, "sandbox": { - "$ref": "#/definitions/v2/SandboxPolicy" + "allOf": [ + { + "$ref": "#/definitions/v2/SandboxPolicy" + } + ], + "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." }, "serviceTier": { "anyOf": [ @@ -15649,6 +16284,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Full permissions override for this thread. Cannot be combined with `sandbox`." + }, "personality": { "anyOf": [ { @@ -15737,6 +16383,18 @@ "modelProvider": { "type": "string" }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PermissionProfile" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Canonical active permissions view for this thread." + }, "reasoningEffort": { "anyOf": [ { @@ -15748,7 +16406,12 @@ ] }, "sandbox": { - "$ref": "#/definitions/v2/SandboxPolicy" + "allOf": [ + { + "$ref": "#/definitions/v2/SandboxPolicy" + } + ], + "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." }, "serviceTier": { "anyOf": [ @@ -16260,6 +16923,21 @@ "title": "TurnDiffUpdatedNotification", "type": "object" }, + "TurnEnvironmentParams": { + "properties": { + "cwd": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "environmentId": { + "type": "string" + } + }, + "required": [ + "cwd", + "environmentId" + ], + "type": "object" + }, "TurnError": { "properties": { "additionalDetails": { @@ -16422,6 +17100,17 @@ "outputSchema": { "description": "Optional JSON Schema used to constrain the final assistant message for this turn." }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Override the full permissions profile for this turn and subsequent turns. Cannot be combined with `sandboxPolicy`." + }, "personality": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 82c990533a..406795ff54 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -46,6 +46,22 @@ ], "title": "ChatgptAccount", "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "amazonBedrock" + ], + "title": "AmazonBedrockAccountType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "AmazonBedrockAccount", + "type": "object" } ] }, @@ -148,6 +164,7 @@ ] }, "read": { + "description": "This will be removed in favor of `entries`.", "items": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -157,6 +174,7 @@ ] }, "write": { + "description": "This will be removed in favor of `entries`.", "items": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -605,9 +623,10 @@ "type": "object" }, "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "type": "string" @@ -777,6 +796,13 @@ "chatgptAuthTokens" ], "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a registered Agent Identity.", + "enum": [ + "agentIdentity" + ], + "type": "string" } ] }, @@ -1129,6 +1155,30 @@ "title": "Thread/shellCommandRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/approveGuardianDeniedAction" + ], + "title": "Thread/approveGuardianDeniedActionRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadApproveGuardianDeniedActionParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/approveGuardianDeniedActionRequest", + "type": "object" + }, { "properties": { "id": { @@ -1346,6 +1396,30 @@ "title": "Marketplace/removeRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "marketplace/upgrade" + ], + "title": "Marketplace/upgradeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/MarketplaceUpgradeParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Marketplace/upgradeRequest", + "type": "object" + }, { "properties": { "id": { @@ -2533,6 +2607,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", @@ -2980,6 +3055,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Optional full permissions profile for this command.\n\nDefaults to the user's configured permissions when omitted. Cannot be combined with `sandboxPolicy`." + }, "processId": { "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ @@ -2996,7 +3082,7 @@ "type": "null" } ], - "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted. Cannot be combined with `permissionProfile`." }, "size": { "anyOf": [ @@ -3873,6 +3959,100 @@ "title": "ConfigWriteResponse", "type": "object" }, + "ConfiguredHookHandler": { + "oneOf": [ + { + "properties": { + "async": { + "type": "boolean" + }, + "command": { + "type": "string" + }, + "statusMessage": { + "type": [ + "string", + "null" + ] + }, + "timeoutSec": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "command" + ], + "title": "CommandConfiguredHookHandlerType", + "type": "string" + } + }, + "required": [ + "async", + "command", + "type" + ], + "title": "CommandConfiguredHookHandler", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "prompt" + ], + "title": "PromptConfiguredHookHandlerType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "PromptConfiguredHookHandler", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "agent" + ], + "title": "AgentConfiguredHookHandlerType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "AgentConfiguredHookHandler", + "type": "object" + } + ] + }, + "ConfiguredHookMatcherGroup": { + "properties": { + "hooks": { + "items": { + "$ref": "#/definitions/ConfiguredHookHandler" + }, + "type": "array" + }, + "matcher": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "hooks" + ], + "type": "object" + }, "ContentItem": { "oneOf": [ { @@ -4912,21 +5092,6 @@ "title": "MinimalFileSystemSpecialPath", "type": "object" }, - { - "properties": { - "kind": { - "enum": [ - "current_working_directory" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "CurrentWorkingDirectoryFileSystemSpecialPath", - "type": "object" - }, { "properties": { "kind": { @@ -5648,38 +5813,6 @@ "title": "GetAccountResponse", "type": "object" }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { - "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] - }, - "preexisting_untracked_dirs": { - "items": { - "type": "string" - }, - "type": "array" - }, - "preexisting_untracked_files": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "id", - "preexisting_untracked_dirs", - "preexisting_untracked_files" - ], - "type": "object" - }, "GitInfo": { "properties": { "branch": { @@ -5975,6 +6108,25 @@ ], "type": "string" }, + "GuardianWarningNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "message": { + "description": "Concise guardian warning message for the user.", + "type": "string" + }, + "threadId": { + "description": "Thread target for the guardian warning.", + "type": "string" + } + }, + "required": [ + "message", + "threadId" + ], + "title": "GuardianWarningNotification", + "type": "object" + }, "HookCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -6702,6 +6854,67 @@ "title": "LogoutAccountResponse", "type": "object" }, + "ManagedHooksRequirements": { + "properties": { + "PermissionRequest": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "PostToolUse": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "PreToolUse": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "SessionStart": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "Stop": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "UserPromptSubmit": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "managedDir": { + "type": [ + "string", + "null" + ] + }, + "windowsManagedDir": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "PermissionRequest", + "PostToolUse", + "PreToolUse", + "SessionStart", + "Stop", + "UserPromptSubmit" + ], + "type": "object" + }, "MarketplaceAddParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -6813,6 +7026,64 @@ "title": "MarketplaceRemoveResponse", "type": "object" }, + "MarketplaceUpgradeErrorInfo": { + "properties": { + "marketplaceName": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "marketplaceName", + "message" + ], + "type": "object" + }, + "MarketplaceUpgradeParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "marketplaceName": { + "type": [ + "string", + "null" + ] + } + }, + "title": "MarketplaceUpgradeParams", + "type": "object" + }, + "MarketplaceUpgradeResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "errors": { + "items": { + "$ref": "#/definitions/MarketplaceUpgradeErrorInfo" + }, + "type": "array" + }, + "selectedMarketplaces": { + "items": { + "type": "string" + }, + "type": "array" + }, + "upgradedRoots": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": [ + "errors", + "selectedMarketplaces", + "upgradedRoots" + ], + "title": "MarketplaceUpgradeResponse", + "type": "object" + }, "McpAuthStatus": { "enum": [ "unsupported", @@ -7422,6 +7693,36 @@ ], "type": "object" }, + "ModelVerification": { + "enum": [ + "trustedAccessForCyber" + ], + "type": "string" + }, + "ModelVerificationNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "verifications": { + "items": { + "$ref": "#/definitions/ModelVerification" + }, + "type": "array" + } + }, + "required": [ + "threadId", + "turnId", + "verifications" + ], + "title": "ModelVerificationNotification", + "type": "object" + }, "NetworkAccess": { "enum": [ "restricted", @@ -7651,6 +7952,135 @@ } ] }, + "PermissionProfile": { + "oneOf": [ + { + "description": "Codex owns sandbox construction for this profile.", + "properties": { + "fileSystem": { + "$ref": "#/definitions/PermissionProfileFileSystemPermissions" + }, + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "managed" + ], + "title": "ManagedPermissionProfileType", + "type": "string" + } + }, + "required": [ + "fileSystem", + "network", + "type" + ], + "title": "ManagedPermissionProfile", + "type": "object" + }, + { + "description": "Do not apply an outer sandbox.", + "properties": { + "type": { + "enum": [ + "disabled" + ], + "title": "DisabledPermissionProfileType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DisabledPermissionProfile", + "type": "object" + }, + { + "description": "Filesystem isolation is enforced by an external caller.", + "properties": { + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "external" + ], + "title": "ExternalPermissionProfileType", + "type": "string" + } + }, + "required": [ + "network", + "type" + ], + "title": "ExternalPermissionProfile", + "type": "object" + } + ] + }, + "PermissionProfileFileSystemPermissions": { + "oneOf": [ + { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "entries", + "type" + ], + "title": "RestrictedPermissionProfileFileSystemPermissions", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "type": "object" + } + ] + }, + "PermissionProfileNetworkPermissions": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, "Personality": { "enum": [ "none", @@ -7727,7 +8157,14 @@ "type": "string" }, "marketplacePath": { - "$ref": "#/definitions/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "mcpServers": { "items": { @@ -7748,7 +8185,6 @@ "required": [ "apps", "marketplaceName", - "marketplacePath", "mcpServers", "skills", "summary" @@ -8459,53 +8895,6 @@ "title": "RawResponseItemCompletedNotification", "type": "object" }, - "ReadOnlyAccess": { - "oneOf": [ - { - "properties": { - "includePlatformDefaults": { - "default": true, - "type": "boolean" - }, - "readableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RestrictedReadOnlyAccess", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "fullAccess" - ], - "title": "FullAccessReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "FullAccessReadOnlyAccess", - "type": "object" - } - ] - }, "RealtimeConversationVersion": { "enum": [ "v1", @@ -8977,12 +9366,6 @@ }, "type": "array" }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, "id": { "type": [ "string", @@ -9382,26 +9765,6 @@ "title": "ImageGenerationCallResponseItem", "type": "object" }, - { - "properties": { - "ghost_commit": { - "$ref": "#/definitions/GhostCommit" - }, - "type": { - "enum": [ - "ghost_snapshot" - ], - "title": "GhostSnapshotResponseItemType", - "type": "string" - } - }, - "required": [ - "ghost_commit", - "type" - ], - "title": "GhostSnapshotResponseItem", - "type": "object" - }, { "properties": { "encrypted_content": { @@ -9714,16 +10077,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -9780,16 +10133,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" @@ -10028,6 +10371,46 @@ "title": "Thread/name/updatedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/goal/updated" + ], + "title": "Thread/goal/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadGoalUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/goal/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/goal/cleared" + ], + "title": "Thread/goal/clearedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadGoalClearedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/goal/clearedNotification", + "type": "object" + }, { "properties": { "method": { @@ -10671,6 +11054,26 @@ "title": "Model/reroutedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "model/verification" + ], + "title": "Model/verificationNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ModelVerificationNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/verificationNotification", + "type": "object" + }, { "properties": { "method": { @@ -10691,6 +11094,26 @@ "title": "WarningNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "guardianWarning" + ], + "title": "GuardianWarningNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/GuardianWarningNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "GuardianWarningNotification", + "type": "object" + }, { "properties": { "method": { @@ -11250,7 +11673,14 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "shortDescription": { "type": [ @@ -11262,8 +11692,7 @@ "required": [ "description", "enabled", - "name", - "path" + "name" ], "type": "object" }, @@ -11746,6 +12175,28 @@ ], "type": "string" }, + "ThreadApproveGuardianDeniedActionParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "event": { + "description": "Serialized `codex_protocol::protocol::GuardianAssessmentEvent`." + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "event", + "threadId" + ], + "title": "ThreadApproveGuardianDeniedActionParams", + "type": "object" + }, + "ThreadApproveGuardianDeniedActionResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadApproveGuardianDeniedActionResponse", + "type": "object" + }, "ThreadArchiveParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -11861,6 +12312,10 @@ "ephemeral": { "type": "boolean" }, + "excludeTurns": { + "description": "When true, return only thread metadata and live fork state without populating `thread.turns`. This is useful when the client plans to call `thread/turns/list` immediately after forking.", + "type": "boolean" + }, "model": { "description": "Configuration overrides for the forked thread, if any.", "type": [ @@ -11874,6 +12329,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Full permissions override for the forked thread. Cannot be combined with `sandbox`." + }, "sandbox": { "anyOf": [ { @@ -11942,6 +12408,18 @@ "modelProvider": { "type": "string" }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Canonical active permissions view for this thread." + }, "reasoningEffort": { "anyOf": [ { @@ -11953,7 +12431,12 @@ ] }, "sandbox": { - "$ref": "#/definitions/SandboxPolicy" + "allOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + } + ], + "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." }, "serviceTier": { "anyOf": [ @@ -11981,6 +12464,97 @@ "title": "ThreadForkResponse", "type": "object" }, + "ThreadGoal": { + "properties": { + "createdAt": { + "format": "int64", + "type": "integer" + }, + "objective": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/ThreadGoalStatus" + }, + "threadId": { + "type": "string" + }, + "timeUsedSeconds": { + "format": "int64", + "type": "integer" + }, + "tokenBudget": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "tokensUsed": { + "format": "int64", + "type": "integer" + }, + "updatedAt": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "createdAt", + "objective", + "status", + "threadId", + "timeUsedSeconds", + "tokensUsed", + "updatedAt" + ], + "type": "object" + }, + "ThreadGoalClearedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadGoalClearedNotification", + "type": "object" + }, + "ThreadGoalStatus": { + "enum": [ + "active", + "paused", + "budgetLimited", + "complete" + ], + "type": "string" + }, + "ThreadGoalUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "goal": { + "$ref": "#/definitions/ThreadGoal" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "goal", + "threadId" + ], + "title": "ThreadGoalUpdatedNotification", + "type": "object" + }, "ThreadId": { "type": "string" }, @@ -12668,6 +13242,19 @@ } ] }, + "ThreadListCwdFilter": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, "ThreadListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12686,11 +13273,15 @@ ] }, "cwd": { - "description": "Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned.", - "type": [ - "string", - "null" - ] + "anyOf": [ + { + "$ref": "#/definitions/ThreadListCwdFilter" + }, + { + "type": "null" + } + ], + "description": "Optional cwd filter or filters; when set, only threads whose session cwd exactly matches one of these paths are returned." }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", @@ -12749,6 +13340,10 @@ "array", "null" ] + }, + "useStateDbOnly": { + "description": "If true, return from the state DB without scanning JSONL rollouts to repair thread metadata. Omitted or false preserves scan-and-repair behavior.", + "type": "boolean" } }, "title": "ThreadListParams", @@ -13241,6 +13836,10 @@ "null" ] }, + "excludeTurns": { + "description": "When true, return only thread metadata and live-resume state without populating `thread.turns`. This is useful when the client plans to call `thread/turns/list` immediately after resuming.", + "type": "boolean" + }, "model": { "description": "Configuration overrides for the resumed thread, if any.", "type": [ @@ -13254,6 +13853,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Full permissions override for the resumed thread. Cannot be combined with `sandbox`." + }, "personality": { "anyOf": [ { @@ -13332,6 +13942,18 @@ "modelProvider": { "type": "string" }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Canonical active permissions view for this thread." + }, "reasoningEffort": { "anyOf": [ { @@ -13343,7 +13965,12 @@ ] }, "sandbox": { - "$ref": "#/definitions/SandboxPolicy" + "allOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + } + ], + "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." }, "serviceTier": { "anyOf": [ @@ -13543,6 +14170,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Full permissions override for this thread. Cannot be combined with `sandbox`." + }, "personality": { "anyOf": [ { @@ -13631,6 +14269,18 @@ "modelProvider": { "type": "string" }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Canonical active permissions view for this thread." + }, "reasoningEffort": { "anyOf": [ { @@ -13642,7 +14292,12 @@ ] }, "sandbox": { - "$ref": "#/definitions/SandboxPolicy" + "allOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + } + ], + "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." }, "serviceTier": { "anyOf": [ @@ -14154,6 +14809,21 @@ "title": "TurnDiffUpdatedNotification", "type": "object" }, + "TurnEnvironmentParams": { + "properties": { + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "environmentId": { + "type": "string" + } + }, + "required": [ + "cwd", + "environmentId" + ], + "type": "object" + }, "TurnError": { "properties": { "additionalDetails": { @@ -14316,6 +14986,17 @@ "outputSchema": { "description": "Optional JSON Schema used to constrain the final assistant message for this turn." }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Override the full permissions profile for this turn and subsequent turns. Cannot be combined with `sandboxPolicy`." + }, "personality": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json index 64109ca2a0..e7546f5570 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json @@ -24,6 +24,13 @@ "chatgptAuthTokens" ], "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a registered Agent Identity.", + "enum": [ + "agentIdentity" + ], + "type": "string" } ] }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json index 986ec4cc1e..7db4d635ee 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json @@ -27,6 +27,202 @@ ], "type": "object" }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "NetworkAccess": { "enum": [ "restricted", @@ -34,53 +230,135 @@ ], "type": "string" }, - "ReadOnlyAccess": { + "PermissionProfile": { "oneOf": [ { + "description": "Codex owns sandbox construction for this profile.", "properties": { - "includePlatformDefaults": { - "default": true, - "type": "boolean" + "fileSystem": { + "$ref": "#/definitions/PermissionProfileFileSystemPermissions" }, - "readableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" }, "type": { "enum": [ - "restricted" + "managed" ], - "title": "RestrictedReadOnlyAccessType", + "title": "ManagedPermissionProfileType", + "type": "string" + } + }, + "required": [ + "fileSystem", + "network", + "type" + ], + "title": "ManagedPermissionProfile", + "type": "object" + }, + { + "description": "Do not apply an outer sandbox.", + "properties": { + "type": { + "enum": [ + "disabled" + ], + "title": "DisabledPermissionProfileType", "type": "string" } }, "required": [ "type" ], - "title": "RestrictedReadOnlyAccess", + "title": "DisabledPermissionProfile", + "type": "object" + }, + { + "description": "Filesystem isolation is enforced by an external caller.", + "properties": { + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "external" + ], + "title": "ExternalPermissionProfileType", + "type": "string" + } + }, + "required": [ + "network", + "type" + ], + "title": "ExternalPermissionProfile", + "type": "object" + } + ] + }, + "PermissionProfileFileSystemPermissions": { + "oneOf": [ + { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "entries", + "type" + ], + "title": "RestrictedPermissionProfileFileSystemPermissions", "type": "object" }, { "properties": { "type": { "enum": [ - "fullAccess" + "unrestricted" ], - "title": "FullAccessReadOnlyAccessType", + "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", "type": "string" } }, "required": [ "type" ], - "title": "FullAccessReadOnlyAccess", + "title": "UnrestrictedPermissionProfileFileSystemPermissions", "type": "object" } ] }, + "PermissionProfileNetworkPermissions": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, "SandboxPolicy": { "oneOf": [ { @@ -101,16 +379,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -167,16 +435,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" @@ -247,6 +505,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Optional full permissions profile for this command.\n\nDefaults to the user's configured permissions when omitted. Cannot be combined with `sandboxPolicy`." + }, "processId": { "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ @@ -263,7 +532,7 @@ "type": "null" } ], - "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted. Cannot be combined with `permissionProfile`." }, "size": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 58f8186266..c348e7d955 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -97,9 +97,10 @@ "type": "object" }, "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 614575a955..545d8dc9b4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -2,9 +2,10 @@ "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "type": "string" @@ -110,6 +111,161 @@ }, "type": "object" }, + "ConfiguredHookHandler": { + "oneOf": [ + { + "properties": { + "async": { + "type": "boolean" + }, + "command": { + "type": "string" + }, + "statusMessage": { + "type": [ + "string", + "null" + ] + }, + "timeoutSec": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "command" + ], + "title": "CommandConfiguredHookHandlerType", + "type": "string" + } + }, + "required": [ + "async", + "command", + "type" + ], + "title": "CommandConfiguredHookHandler", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "prompt" + ], + "title": "PromptConfiguredHookHandlerType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "PromptConfiguredHookHandler", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "agent" + ], + "title": "AgentConfiguredHookHandlerType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "AgentConfiguredHookHandler", + "type": "object" + } + ] + }, + "ConfiguredHookMatcherGroup": { + "properties": { + "hooks": { + "items": { + "$ref": "#/definitions/ConfiguredHookHandler" + }, + "type": "array" + }, + "matcher": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "hooks" + ], + "type": "object" + }, + "ManagedHooksRequirements": { + "properties": { + "PermissionRequest": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "PostToolUse": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "PreToolUse": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "SessionStart": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "Stop": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "UserPromptSubmit": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "managedDir": { + "type": [ + "string", + "null" + ] + }, + "windowsManagedDir": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "PermissionRequest", + "PostToolUse", + "PreToolUse", + "SessionStart", + "Stop", + "UserPromptSubmit" + ], + "type": "object" + }, "NetworkDomainPermission": { "enum": [ "allow", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json index 032743d55d..fd55d08764 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json @@ -9,6 +9,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json index 8534927157..ec333708b7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json @@ -42,6 +42,22 @@ ], "title": "ChatgptAccount", "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "amazonBedrock" + ], + "title": "AmazonBedrockAccountType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "AmazonBedrockAccount", + "type": "object" } ] }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/GuardianWarningNotification.json b/codex-rs/app-server-protocol/schema/json/v2/GuardianWarningNotification.json new file mode 100644 index 0000000000..5a4ef82f0a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/GuardianWarningNotification.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "message": { + "description": "Concise guardian warning message for the user.", + "type": "string" + }, + "threadId": { + "description": "Thread target for the guardian warning.", + "type": "string" + } + }, + "required": [ + "message", + "threadId" + ], + "title": "GuardianWarningNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json index 27716e59b0..98f44e50a2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json @@ -25,6 +25,7 @@ ] }, "read": { + "description": "This will be removed in favor of `entries`.", "items": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -34,6 +35,7 @@ ] }, "write": { + "description": "This will be removed in favor of `entries`.", "items": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -182,21 +184,6 @@ "title": "MinimalFileSystemSpecialPath", "type": "object" }, - { - "properties": { - "kind": { - "enum": [ - "current_working_directory" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "CurrentWorkingDirectoryFileSystemSpecialPath", - "type": "object" - }, { "properties": { "kind": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json index c039f2191a..16e47c2d72 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json @@ -25,6 +25,7 @@ ] }, "read": { + "description": "This will be removed in favor of `entries`.", "items": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -34,6 +35,7 @@ ] }, "write": { + "description": "This will be removed in favor of `entries`.", "items": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -175,21 +177,6 @@ "title": "MinimalFileSystemSpecialPath", "type": "object" }, - { - "properties": { - "kind": { - "enum": [ - "current_working_directory" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "CurrentWorkingDirectoryFileSystemSpecialPath", - "type": "object" - }, { "properties": { "kind": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/MarketplaceUpgradeParams.json b/codex-rs/app-server-protocol/schema/json/v2/MarketplaceUpgradeParams.json new file mode 100644 index 0000000000..684d134cb6 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/MarketplaceUpgradeParams.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "marketplaceName": { + "type": [ + "string", + "null" + ] + } + }, + "title": "MarketplaceUpgradeParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/MarketplaceUpgradeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/MarketplaceUpgradeResponse.json new file mode 100644 index 0000000000..6788241632 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/MarketplaceUpgradeResponse.json @@ -0,0 +1,51 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "MarketplaceUpgradeErrorInfo": { + "properties": { + "marketplaceName": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "marketplaceName", + "message" + ], + "type": "object" + } + }, + "properties": { + "errors": { + "items": { + "$ref": "#/definitions/MarketplaceUpgradeErrorInfo" + }, + "type": "array" + }, + "selectedMarketplaces": { + "items": { + "type": "string" + }, + "type": "array" + }, + "upgradedRoots": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": [ + "errors", + "selectedMarketplaces", + "upgradedRoots" + ], + "title": "MarketplaceUpgradeResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ModelVerificationNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ModelVerificationNotification.json new file mode 100644 index 0000000000..aea6b628eb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ModelVerificationNotification.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ModelVerification": { + "enum": [ + "trustedAccessForCyber" + ], + "type": "string" + } + }, + "properties": { + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "verifications": { + "items": { + "$ref": "#/definitions/ModelVerification" + }, + "type": "array" + } + }, + "required": [ + "threadId", + "turnId", + "verifications" + ], + "title": "ModelVerificationNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index 5ec07f00f1..6e04ef74b0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -62,7 +62,14 @@ "type": "string" }, "marketplacePath": { - "$ref": "#/definitions/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "mcpServers": { "items": { @@ -83,7 +90,6 @@ "required": [ "apps", "marketplaceName", - "marketplacePath", "mcpServers", "skills", "summary" @@ -423,7 +429,14 @@ "type": "string" }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] }, "shortDescription": { "type": [ @@ -435,8 +448,7 @@ "required": [ "description", "enabled", - "name", - "path" + "name" ], "type": "object" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index 956e3b2507..92117cf36d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -143,38 +143,6 @@ } ] }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { - "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] - }, - "preexisting_untracked_dirs": { - "items": { - "type": "string" - }, - "type": "array" - }, - "preexisting_untracked_files": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "id", - "preexisting_untracked_dirs", - "preexisting_untracked_files" - ], - "type": "object" - }, "ImageDetail": { "enum": [ "auto", @@ -345,12 +313,6 @@ }, "type": "array" }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, "id": { "type": [ "string", @@ -750,26 +712,6 @@ "title": "ImageGenerationCallResponseItem", "type": "object" }, - { - "properties": { - "ghost_commit": { - "$ref": "#/definitions/GhostCommit" - }, - "type": { - "enum": [ - "ghost_snapshot" - ], - "title": "GhostSnapshotResponseItemType", - "type": "string" - } - }, - "required": [ - "ghost_commit", - "type" - ], - "title": "GhostSnapshotResponseItem", - "type": "object" - }, { "properties": { "encrypted_content": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index e8500d7aeb..16abcd7806 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -32,6 +32,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadApproveGuardianDeniedActionParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadApproveGuardianDeniedActionParams.json new file mode 100644 index 0000000000..3938815ecd --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadApproveGuardianDeniedActionParams.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "event": { + "description": "Serialized `codex_protocol::protocol::GuardianAssessmentEvent`." + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "event", + "threadId" + ], + "title": "ThreadApproveGuardianDeniedActionParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadApproveGuardianDeniedActionResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadApproveGuardianDeniedActionResponse.json new file mode 100644 index 0000000000..b173819cc3 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadApproveGuardianDeniedActionResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadApproveGuardianDeniedActionResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index a8fa95e2e9..504dbdc67e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -1,10 +1,15 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "type": "string" @@ -59,6 +64,331 @@ } ] }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, + "PermissionProfile": { + "oneOf": [ + { + "description": "Codex owns sandbox construction for this profile.", + "properties": { + "fileSystem": { + "$ref": "#/definitions/PermissionProfileFileSystemPermissions" + }, + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "managed" + ], + "title": "ManagedPermissionProfileType", + "type": "string" + } + }, + "required": [ + "fileSystem", + "network", + "type" + ], + "title": "ManagedPermissionProfile", + "type": "object" + }, + { + "description": "Do not apply an outer sandbox.", + "properties": { + "type": { + "enum": [ + "disabled" + ], + "title": "DisabledPermissionProfileType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DisabledPermissionProfile", + "type": "object" + }, + { + "description": "Filesystem isolation is enforced by an external caller.", + "properties": { + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "external" + ], + "title": "ExternalPermissionProfileType", + "type": "string" + } + }, + "required": [ + "network", + "type" + ], + "title": "ExternalPermissionProfile", + "type": "object" + } + ] + }, + "PermissionProfileFileSystemPermissions": { + "oneOf": [ + { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "entries", + "type" + ], + "title": "RestrictedPermissionProfileFileSystemPermissions", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "type": "object" + } + ] + }, + "PermissionProfileNetworkPermissions": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, "SandboxMode": { "enum": [ "read-only", @@ -126,6 +456,10 @@ "ephemeral": { "type": "boolean" }, + "excludeTurns": { + "description": "When true, return only thread metadata and live fork state without populating `thread.turns`. This is useful when the client plans to call `thread/turns/list` immediately after forking.", + "type": "boolean" + }, "model": { "description": "Configuration overrides for the forked thread, if any.", "type": [ @@ -139,6 +473,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Full permissions override for the forked thread. Cannot be combined with `sandbox`." + }, "sandbox": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index a01f7bee71..3ab0077d0a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -9,9 +9,10 @@ "type": "string" }, "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "type": "string" @@ -93,6 +94,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", @@ -448,6 +450,202 @@ ], "type": "string" }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "FileUpdateChange": { "properties": { "diff": { @@ -686,53 +884,135 @@ } ] }, - "ReadOnlyAccess": { + "PermissionProfile": { "oneOf": [ { + "description": "Codex owns sandbox construction for this profile.", "properties": { - "includePlatformDefaults": { - "default": true, - "type": "boolean" + "fileSystem": { + "$ref": "#/definitions/PermissionProfileFileSystemPermissions" }, - "readableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" }, "type": { "enum": [ - "restricted" + "managed" ], - "title": "RestrictedReadOnlyAccessType", + "title": "ManagedPermissionProfileType", + "type": "string" + } + }, + "required": [ + "fileSystem", + "network", + "type" + ], + "title": "ManagedPermissionProfile", + "type": "object" + }, + { + "description": "Do not apply an outer sandbox.", + "properties": { + "type": { + "enum": [ + "disabled" + ], + "title": "DisabledPermissionProfileType", "type": "string" } }, "required": [ "type" ], - "title": "RestrictedReadOnlyAccess", + "title": "DisabledPermissionProfile", + "type": "object" + }, + { + "description": "Filesystem isolation is enforced by an external caller.", + "properties": { + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "external" + ], + "title": "ExternalPermissionProfileType", + "type": "string" + } + }, + "required": [ + "network", + "type" + ], + "title": "ExternalPermissionProfile", + "type": "object" + } + ] + }, + "PermissionProfileFileSystemPermissions": { + "oneOf": [ + { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "entries", + "type" + ], + "title": "RestrictedPermissionProfileFileSystemPermissions", "type": "object" }, { "properties": { "type": { "enum": [ - "fullAccess" + "unrestricted" ], - "title": "FullAccessReadOnlyAccessType", + "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", "type": "string" } }, "required": [ "type" ], - "title": "FullAccessReadOnlyAccess", + "title": "UnrestrictedPermissionProfileFileSystemPermissions", "type": "object" } ] }, + "PermissionProfileNetworkPermissions": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -765,16 +1045,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -831,16 +1101,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" @@ -2225,6 +2485,18 @@ "modelProvider": { "type": "string" }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Canonical active permissions view for this thread." + }, "reasoningEffort": { "anyOf": [ { @@ -2236,7 +2508,12 @@ ] }, "sandbox": { - "$ref": "#/definitions/SandboxPolicy" + "allOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + } + ], + "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." }, "serviceTier": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalClearedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalClearedNotification.json new file mode 100644 index 0000000000..c1fe94b910 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalClearedNotification.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadGoalClearedNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json new file mode 100644 index 0000000000..52a2e905a2 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json @@ -0,0 +1,80 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ThreadGoal": { + "properties": { + "createdAt": { + "format": "int64", + "type": "integer" + }, + "objective": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/ThreadGoalStatus" + }, + "threadId": { + "type": "string" + }, + "timeUsedSeconds": { + "format": "int64", + "type": "integer" + }, + "tokenBudget": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "tokensUsed": { + "format": "int64", + "type": "integer" + }, + "updatedAt": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "createdAt", + "objective", + "status", + "threadId", + "timeUsedSeconds", + "tokensUsed", + "updatedAt" + ], + "type": "object" + }, + "ThreadGoalStatus": { + "enum": [ + "active", + "paused", + "budgetLimited", + "complete" + ], + "type": "string" + } + }, + "properties": { + "goal": { + "$ref": "#/definitions/ThreadGoal" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "goal", + "threadId" + ], + "title": "ThreadGoalUpdatedNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json index 62a7dc9c5b..789d9b61fc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json @@ -8,6 +8,19 @@ ], "type": "string" }, + "ThreadListCwdFilter": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, "ThreadSortKey": { "enum": [ "created_at", @@ -47,11 +60,15 @@ ] }, "cwd": { - "description": "Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned.", - "type": [ - "string", - "null" - ] + "anyOf": [ + { + "$ref": "#/definitions/ThreadListCwdFilter" + }, + { + "type": "null" + } + ], + "description": "Optional cwd filter or filters; when set, only threads whose session cwd exactly matches one of these paths are returned." }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", @@ -110,6 +127,10 @@ "array", "null" ] + }, + "useStateDbOnly": { + "description": "If true, return from the state DB without scanning JSONL rollouts to repair thread metadata. Omitted or false preserves scan-and-repair behavior.", + "type": "boolean" } }, "title": "ThreadListParams", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index d3da9b7b4c..2f5cbb9500 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -35,6 +35,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 33207ca114..b9ae59708a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -35,6 +35,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 9ba4406525..cda474c294 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -35,6 +35,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 35a9e5b2a8..63d0345dae 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -1,10 +1,15 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "type": "string" @@ -133,6 +138,202 @@ } ] }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "FunctionCallOutputBody": { "anyOf": [ { @@ -201,38 +402,6 @@ } ] }, - "GhostCommit": { - "description": "Details of a ghost commit created from a repository state.", - "properties": { - "id": { - "type": "string" - }, - "parent": { - "type": [ - "string", - "null" - ] - }, - "preexisting_untracked_dirs": { - "items": { - "type": "string" - }, - "type": "array" - }, - "preexisting_untracked_files": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "id", - "preexisting_untracked_dirs", - "preexisting_untracked_files" - ], - "type": "object" - }, "ImageDetail": { "enum": [ "auto", @@ -325,6 +494,135 @@ } ] }, + "PermissionProfile": { + "oneOf": [ + { + "description": "Codex owns sandbox construction for this profile.", + "properties": { + "fileSystem": { + "$ref": "#/definitions/PermissionProfileFileSystemPermissions" + }, + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "managed" + ], + "title": "ManagedPermissionProfileType", + "type": "string" + } + }, + "required": [ + "fileSystem", + "network", + "type" + ], + "title": "ManagedPermissionProfile", + "type": "object" + }, + { + "description": "Do not apply an outer sandbox.", + "properties": { + "type": { + "enum": [ + "disabled" + ], + "title": "DisabledPermissionProfileType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DisabledPermissionProfile", + "type": "object" + }, + { + "description": "Filesystem isolation is enforced by an external caller.", + "properties": { + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "external" + ], + "title": "ExternalPermissionProfileType", + "type": "string" + } + }, + "required": [ + "network", + "type" + ], + "title": "ExternalPermissionProfile", + "type": "object" + } + ] + }, + "PermissionProfileFileSystemPermissions": { + "oneOf": [ + { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "entries", + "type" + ], + "title": "RestrictedPermissionProfileFileSystemPermissions", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "type": "object" + } + ] + }, + "PermissionProfileNetworkPermissions": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, "Personality": { "enum": [ "none", @@ -411,12 +709,6 @@ }, "type": "array" }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, "id": { "type": [ "string", @@ -816,26 +1108,6 @@ "title": "ImageGenerationCallResponseItem", "type": "object" }, - { - "properties": { - "ghost_commit": { - "$ref": "#/definitions/GhostCommit" - }, - "type": { - "enum": [ - "ghost_snapshot" - ], - "title": "GhostSnapshotResponseItemType", - "type": "string" - } - }, - "required": [ - "ghost_commit", - "type" - ], - "title": "GhostSnapshotResponseItem", - "type": "object" - }, { "properties": { "encrypted_content": { @@ -1039,6 +1311,10 @@ "null" ] }, + "excludeTurns": { + "description": "When true, return only thread metadata and live-resume state without populating `thread.turns`. This is useful when the client plans to call `thread/turns/list` immediately after resuming.", + "type": "boolean" + }, "model": { "description": "Configuration overrides for the resumed thread, if any.", "type": [ @@ -1052,6 +1328,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Full permissions override for the resumed thread. Cannot be combined with `sandbox`." + }, "personality": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 6d504c6004..12c38db5dc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -9,9 +9,10 @@ "type": "string" }, "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "type": "string" @@ -93,6 +94,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", @@ -448,6 +450,202 @@ ], "type": "string" }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "FileUpdateChange": { "properties": { "diff": { @@ -686,53 +884,135 @@ } ] }, - "ReadOnlyAccess": { + "PermissionProfile": { "oneOf": [ { + "description": "Codex owns sandbox construction for this profile.", "properties": { - "includePlatformDefaults": { - "default": true, - "type": "boolean" + "fileSystem": { + "$ref": "#/definitions/PermissionProfileFileSystemPermissions" }, - "readableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" }, "type": { "enum": [ - "restricted" + "managed" ], - "title": "RestrictedReadOnlyAccessType", + "title": "ManagedPermissionProfileType", + "type": "string" + } + }, + "required": [ + "fileSystem", + "network", + "type" + ], + "title": "ManagedPermissionProfile", + "type": "object" + }, + { + "description": "Do not apply an outer sandbox.", + "properties": { + "type": { + "enum": [ + "disabled" + ], + "title": "DisabledPermissionProfileType", "type": "string" } }, "required": [ "type" ], - "title": "RestrictedReadOnlyAccess", + "title": "DisabledPermissionProfile", + "type": "object" + }, + { + "description": "Filesystem isolation is enforced by an external caller.", + "properties": { + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "external" + ], + "title": "ExternalPermissionProfileType", + "type": "string" + } + }, + "required": [ + "network", + "type" + ], + "title": "ExternalPermissionProfile", + "type": "object" + } + ] + }, + "PermissionProfileFileSystemPermissions": { + "oneOf": [ + { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "entries", + "type" + ], + "title": "RestrictedPermissionProfileFileSystemPermissions", "type": "object" }, { "properties": { "type": { "enum": [ - "fullAccess" + "unrestricted" ], - "title": "FullAccessReadOnlyAccessType", + "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", "type": "string" } }, "required": [ "type" ], - "title": "FullAccessReadOnlyAccess", + "title": "UnrestrictedPermissionProfileFileSystemPermissions", "type": "object" } ] }, + "PermissionProfileNetworkPermissions": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -765,16 +1045,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -831,16 +1101,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" @@ -2225,6 +2485,18 @@ "modelProvider": { "type": "string" }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Canonical active permissions view for this thread." + }, "reasoningEffort": { "anyOf": [ { @@ -2236,7 +2508,12 @@ ] }, "sandbox": { - "$ref": "#/definitions/SandboxPolicy" + "allOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + } + ], + "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." }, "serviceTier": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index b531ed047b..e5339f4e99 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -35,6 +35,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index 9b1cba6d1a..bc8d83d2bc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -1,10 +1,15 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "type": "string" @@ -85,6 +90,331 @@ ], "type": "object" }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, + "PermissionProfile": { + "oneOf": [ + { + "description": "Codex owns sandbox construction for this profile.", + "properties": { + "fileSystem": { + "$ref": "#/definitions/PermissionProfileFileSystemPermissions" + }, + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "managed" + ], + "title": "ManagedPermissionProfileType", + "type": "string" + } + }, + "required": [ + "fileSystem", + "network", + "type" + ], + "title": "ManagedPermissionProfile", + "type": "object" + }, + { + "description": "Do not apply an outer sandbox.", + "properties": { + "type": { + "enum": [ + "disabled" + ], + "title": "DisabledPermissionProfileType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DisabledPermissionProfile", + "type": "object" + }, + { + "description": "Filesystem isolation is enforced by an external caller.", + "properties": { + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "external" + ], + "title": "ExternalPermissionProfileType", + "type": "string" + } + }, + "required": [ + "network", + "type" + ], + "title": "ExternalPermissionProfile", + "type": "object" + } + ] + }, + "PermissionProfileFileSystemPermissions": { + "oneOf": [ + { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "entries", + "type" + ], + "title": "RestrictedPermissionProfileFileSystemPermissions", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "type": "object" + } + ] + }, + "PermissionProfileNetworkPermissions": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, "Personality": { "enum": [ "none", @@ -114,6 +444,21 @@ "clear" ], "type": "string" + }, + "TurnEnvironmentParams": { + "properties": { + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "environmentId": { + "type": "string" + } + }, + "required": [ + "cwd", + "environmentId" + ], + "type": "object" } }, "properties": { @@ -181,6 +526,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Full permissions override for this thread. Cannot be combined with `sandbox`." + }, "personality": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 0d0ff5d9dc..6e1637ec80 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -9,9 +9,10 @@ "type": "string" }, "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "type": "string" @@ -93,6 +94,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", @@ -448,6 +450,202 @@ ], "type": "string" }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "FileUpdateChange": { "properties": { "diff": { @@ -686,53 +884,135 @@ } ] }, - "ReadOnlyAccess": { + "PermissionProfile": { "oneOf": [ { + "description": "Codex owns sandbox construction for this profile.", "properties": { - "includePlatformDefaults": { - "default": true, - "type": "boolean" + "fileSystem": { + "$ref": "#/definitions/PermissionProfileFileSystemPermissions" }, - "readableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" }, "type": { "enum": [ - "restricted" + "managed" ], - "title": "RestrictedReadOnlyAccessType", + "title": "ManagedPermissionProfileType", + "type": "string" + } + }, + "required": [ + "fileSystem", + "network", + "type" + ], + "title": "ManagedPermissionProfile", + "type": "object" + }, + { + "description": "Do not apply an outer sandbox.", + "properties": { + "type": { + "enum": [ + "disabled" + ], + "title": "DisabledPermissionProfileType", "type": "string" } }, "required": [ "type" ], - "title": "RestrictedReadOnlyAccess", + "title": "DisabledPermissionProfile", + "type": "object" + }, + { + "description": "Filesystem isolation is enforced by an external caller.", + "properties": { + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "external" + ], + "title": "ExternalPermissionProfileType", + "type": "string" + } + }, + "required": [ + "network", + "type" + ], + "title": "ExternalPermissionProfile", + "type": "object" + } + ] + }, + "PermissionProfileFileSystemPermissions": { + "oneOf": [ + { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "entries", + "type" + ], + "title": "RestrictedPermissionProfileFileSystemPermissions", "type": "object" }, { "properties": { "type": { "enum": [ - "fullAccess" + "unrestricted" ], - "title": "FullAccessReadOnlyAccessType", + "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", "type": "string" } }, "required": [ "type" ], - "title": "FullAccessReadOnlyAccess", + "title": "UnrestrictedPermissionProfileFileSystemPermissions", "type": "object" } ] }, + "PermissionProfileNetworkPermissions": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -765,16 +1045,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -831,16 +1101,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" @@ -2225,6 +2485,18 @@ "modelProvider": { "type": "string" }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Canonical active permissions view for this thread." + }, "reasoningEffort": { "anyOf": [ { @@ -2236,7 +2508,12 @@ ] }, "sandbox": { - "$ref": "#/definitions/SandboxPolicy" + "allOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + } + ], + "description": "Legacy sandbox policy retained for compatibility. New clients should use `permissionProfile` when present as the canonical active permissions view." }, "serviceTier": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 165b4a3109..774686e46a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -35,6 +35,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json index 8d4ee72e17..eaa3becdea 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json @@ -32,6 +32,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 0b414ef422..64179af7e1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -35,6 +35,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 05395f4f64..0739fa31bc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -32,6 +32,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index cad1d8b5bc..d866c03152 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -6,9 +6,10 @@ "type": "string" }, "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "type": "string" @@ -98,6 +99,202 @@ ], "type": "object" }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "ModeKind": { "description": "Initial collaboration mode to use when the TUI starts.", "enum": [ @@ -113,6 +310,135 @@ ], "type": "string" }, + "PermissionProfile": { + "oneOf": [ + { + "description": "Codex owns sandbox construction for this profile.", + "properties": { + "fileSystem": { + "$ref": "#/definitions/PermissionProfileFileSystemPermissions" + }, + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "managed" + ], + "title": "ManagedPermissionProfileType", + "type": "string" + } + }, + "required": [ + "fileSystem", + "network", + "type" + ], + "title": "ManagedPermissionProfile", + "type": "object" + }, + { + "description": "Do not apply an outer sandbox.", + "properties": { + "type": { + "enum": [ + "disabled" + ], + "title": "DisabledPermissionProfileType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DisabledPermissionProfile", + "type": "object" + }, + { + "description": "Filesystem isolation is enforced by an external caller.", + "properties": { + "network": { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + "type": { + "enum": [ + "external" + ], + "title": "ExternalPermissionProfileType", + "type": "string" + } + }, + "required": [ + "network", + "type" + ], + "title": "ExternalPermissionProfile", + "type": "object" + } + ] + }, + "PermissionProfileFileSystemPermissions": { + "oneOf": [ + { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "entries", + "type" + ], + "title": "RestrictedPermissionProfileFileSystemPermissions", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "type": "object" + } + ] + }, + "PermissionProfileNetworkPermissions": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, "Personality": { "enum": [ "none", @@ -121,53 +447,6 @@ ], "type": "string" }, - "ReadOnlyAccess": { - "oneOf": [ - { - "properties": { - "includePlatformDefaults": { - "default": true, - "type": "boolean" - }, - "readableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RestrictedReadOnlyAccess", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "fullAccess" - ], - "title": "FullAccessReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "FullAccessReadOnlyAccess", - "type": "object" - } - ] - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -220,16 +499,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -286,16 +555,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" @@ -377,6 +636,21 @@ ], "type": "object" }, + "TurnEnvironmentParams": { + "properties": { + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "environmentId": { + "type": "string" + } + }, + "required": [ + "cwd", + "environmentId" + ], + "type": "object" + }, "UserInput": { "oneOf": [ { @@ -555,6 +829,17 @@ "outputSchema": { "description": "Optional JSON Schema used to constrain the final assistant message for this turn." }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Override the full permissions profile for this turn and subsequent turns. Cannot be combined with `sandboxPolicy`." + }, "personality": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index c818dd8c7e..bc5917ef15 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -32,6 +32,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 3c8eafcdb5..22ad85d906 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -32,6 +32,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts b/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts index 5e0cad8864..210e54c4a5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts +++ b/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts @@ -5,4 +5,4 @@ /** * Authentication mode for OpenAI-backed providers. */ -export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens"; +export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity"; diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index fdb934ed53..7aaa17461c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -38,6 +38,7 @@ import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams"; import type { LoginAccountParams } from "./v2/LoginAccountParams"; import type { MarketplaceAddParams } from "./v2/MarketplaceAddParams"; import type { MarketplaceRemoveParams } from "./v2/MarketplaceRemoveParams"; +import type { MarketplaceUpgradeParams } from "./v2/MarketplaceUpgradeParams"; import type { McpResourceReadParams } from "./v2/McpResourceReadParams"; import type { McpServerOauthLoginParams } from "./v2/McpServerOauthLoginParams"; import type { McpServerToolCallParams } from "./v2/McpServerToolCallParams"; @@ -50,6 +51,7 @@ import type { ReviewStartParams } from "./v2/ReviewStartParams"; import type { SendAddCreditsNudgeEmailParams } from "./v2/SendAddCreditsNudgeEmailParams"; import type { SkillsConfigWriteParams } from "./v2/SkillsConfigWriteParams"; import type { SkillsListParams } from "./v2/SkillsListParams"; +import type { ThreadApproveGuardianDeniedActionParams } from "./v2/ThreadApproveGuardianDeniedActionParams"; import type { ThreadArchiveParams } from "./v2/ThreadArchiveParams"; import type { ThreadCompactStartParams } from "./v2/ThreadCompactStartParams"; import type { ThreadForkParams } from "./v2/ThreadForkParams"; @@ -74,4 +76,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/turns/list", id: RequestId, params: ThreadTurnsListParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/turns/list", id: RequestId, params: ThreadTurnsListParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/GhostCommit.ts b/codex-rs/app-server-protocol/schema/typescript/GhostCommit.ts deleted file mode 100644 index d7b927492b..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/GhostCommit.ts +++ /dev/null @@ -1,8 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Details of a ghost commit created from a repository state. - */ -export type GhostCommit = { id: string, parent: string | null, preexisting_untracked_files: Array, preexisting_untracked_dirs: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index 04b8bdcdad..382c89db7d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -3,7 +3,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ContentItem } from "./ContentItem"; import type { FunctionCallOutputBody } from "./FunctionCallOutputBody"; -import type { GhostCommit } from "./GhostCommit"; import type { LocalShellAction } from "./LocalShellAction"; import type { LocalShellStatus } from "./LocalShellStatus"; import type { MessagePhase } from "./MessagePhase"; @@ -11,8 +10,8 @@ import type { ReasoningItemContent } from "./ReasoningItemContent"; import type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSummary"; import type { WebSearchAction } from "./WebSearchAction"; -export type ResponseItem = { "type": "message", role: string, content: Array, end_turn?: boolean, phase?: MessagePhase, } | { "type": "reasoning", summary: Array, content?: Array, encrypted_content: string | null, } | { "type": "local_shell_call", +export type ResponseItem = { "type": "message", role: string, content: Array, phase?: MessagePhase, } | { "type": "reasoning", summary: Array, content?: Array, encrypted_content: string | null, } | { "type": "local_shell_call", /** * Set when using the Responses API. */ -call_id: string | null, status: LocalShellStatus, action: LocalShellAction, } | { "type": "function_call", name: string, namespace?: string, arguments: string, call_id: string, } | { "type": "tool_search_call", call_id: string | null, status?: string, execution: string, arguments: unknown, } | { "type": "function_call_output", call_id: string, output: FunctionCallOutputBody, } | { "type": "custom_tool_call", status?: string, call_id: string, name: string, input: string, } | { "type": "custom_tool_call_output", call_id: string, name?: string, output: FunctionCallOutputBody, } | { "type": "tool_search_output", call_id: string | null, status: string, execution: string, tools: unknown[], } | { "type": "web_search_call", status?: string, action?: WebSearchAction, } | { "type": "image_generation_call", id: string, status: string, revised_prompt?: string, result: string, } | { "type": "ghost_snapshot", ghost_commit: GhostCommit, } | { "type": "compaction", encrypted_content: string, } | { "type": "other" }; +call_id: string | null, status: LocalShellStatus, action: LocalShellAction, } | { "type": "function_call", name: string, namespace?: string, arguments: string, call_id: string, } | { "type": "tool_search_call", call_id: string | null, status?: string, execution: string, arguments: unknown, } | { "type": "function_call_output", call_id: string, output: FunctionCallOutputBody, } | { "type": "custom_tool_call", status?: string, call_id: string, name: string, input: string, } | { "type": "custom_tool_call_output", call_id: string, name?: string, output: FunctionCallOutputBody, } | { "type": "tool_search_output", call_id: string | null, status: string, execution: string, tools: unknown[], } | { "type": "web_search_call", status?: string, action?: WebSearchAction, } | { "type": "image_generation_call", id: string, status: string, revised_prompt?: string, result: string, } | { "type": "compaction", encrypted_content: string, } | { "type": "other" }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index f55700bf54..41d4754bc3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -18,6 +18,7 @@ import type { ExternalAgentConfigImportCompletedNotification } from "./v2/Extern import type { FileChangeOutputDeltaNotification } from "./v2/FileChangeOutputDeltaNotification"; import type { FileChangePatchUpdatedNotification } from "./v2/FileChangePatchUpdatedNotification"; import type { FsChangedNotification } from "./v2/FsChangedNotification"; +import type { GuardianWarningNotification } from "./v2/GuardianWarningNotification"; import type { HookCompletedNotification } from "./v2/HookCompletedNotification"; import type { HookStartedNotification } from "./v2/HookStartedNotification"; import type { ItemCompletedNotification } from "./v2/ItemCompletedNotification"; @@ -28,6 +29,7 @@ import type { McpServerOauthLoginCompletedNotification } from "./v2/McpServerOau import type { McpServerStatusUpdatedNotification } from "./v2/McpServerStatusUpdatedNotification"; import type { McpToolCallProgressNotification } from "./v2/McpToolCallProgressNotification"; import type { ModelReroutedNotification } from "./v2/ModelReroutedNotification"; +import type { ModelVerificationNotification } from "./v2/ModelVerificationNotification"; import type { PlanDeltaNotification } from "./v2/PlanDeltaNotification"; import type { RawResponseItemCompletedNotification } from "./v2/RawResponseItemCompletedNotification"; import type { ReasoningSummaryPartAddedNotification } from "./v2/ReasoningSummaryPartAddedNotification"; @@ -38,6 +40,8 @@ import type { SkillsChangedNotification } from "./v2/SkillsChangedNotification"; import type { TerminalInteractionNotification } from "./v2/TerminalInteractionNotification"; import type { ThreadArchivedNotification } from "./v2/ThreadArchivedNotification"; import type { ThreadClosedNotification } from "./v2/ThreadClosedNotification"; +import type { ThreadGoalClearedNotification } from "./v2/ThreadGoalClearedNotification"; +import type { ThreadGoalUpdatedNotification } from "./v2/ThreadGoalUpdatedNotification"; import type { ThreadNameUpdatedNotification } from "./v2/ThreadNameUpdatedNotification"; import type { ThreadRealtimeClosedNotification } from "./v2/ThreadRealtimeClosedNotification"; import type { ThreadRealtimeErrorNotification } from "./v2/ThreadRealtimeErrorNotification"; @@ -62,4 +66,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "warning", "params": WarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 7bbb417fdc..a5d3dbe57d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -29,7 +29,6 @@ export type { GetAuthStatusParams } from "./GetAuthStatusParams"; export type { GetAuthStatusResponse } from "./GetAuthStatusResponse"; export type { GetConversationSummaryParams } from "./GetConversationSummaryParams"; export type { GetConversationSummaryResponse } from "./GetConversationSummaryResponse"; -export type { GhostCommit } from "./GhostCommit"; export type { GitDiffToRemoteParams } from "./GitDiffToRemoteParams"; export type { GitDiffToRemoteResponse } from "./GitDiffToRemoteResponse"; export type { GitSha } from "./GitSha"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts index f91677499e..4c3a58e8d6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { PlanType } from "../PlanType"; -export type Account = { "type": "apiKey", } | { "type": "chatgpt", email: string, planType: PlanType, }; +export type Account = { "type": "apiKey", } | { "type": "chatgpt", email: string, planType: PlanType, } | { "type": "amazonBedrock", }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalFileSystemPermissions.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalFileSystemPermissions.ts index 2be8f7f0e7..e29263b95f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalFileSystemPermissions.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalFileSystemPermissions.ts @@ -4,4 +4,12 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { FileSystemSandboxEntry } from "./FileSystemSandboxEntry"; -export type AdditionalFileSystemPermissions = { read: Array | null, write: Array | null, globScanMaxDepth?: number, entries?: Array, }; +export type AdditionalFileSystemPermissions = { +/** + * This will be removed in favor of `entries`. + */ +read: Array | null, +/** + * This will be removed in favor of `entries`. + */ +write: Array | null, globScanMaxDepth?: number, entries?: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalPermissionProfile.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalPermissionProfile.ts index 65836c119d..5120ec3135 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalPermissionProfile.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalPermissionProfile.ts @@ -4,4 +4,8 @@ import type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; import type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions"; -export type AdditionalPermissionProfile = { network: AdditionalNetworkPermissions | null, fileSystem: AdditionalFileSystemPermissions | null, }; +export type AdditionalPermissionProfile = { +/** + * Partial overlay used for per-command permission requests. + */ +network: AdditionalNetworkPermissions | null, fileSystem: AdditionalFileSystemPermissions | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ApprovalsReviewer.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ApprovalsReviewer.ts index a5a26ae0e4..1d932946cc 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ApprovalsReviewer.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ApprovalsReviewer.ts @@ -5,8 +5,8 @@ /** * Configures who approval requests are routed to for review. Examples * include sandbox escapes, blocked network access, MCP approval prompts, and - * ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully + * ARC escalations. Defaults to `user`. `auto_review` uses a carefully * prompted subagent to gather relevant context and apply a risk-based * decision framework before approving or denying the request. */ -export type ApprovalsReviewer = "user" | "guardian_subagent"; +export type ApprovalsReviewer = "user" | "auto_review" | "guardian_subagent"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts index 20dc3c5191..6e975abf41 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts @@ -9,4 +9,4 @@ import type { NonSteerableTurnKind } from "./NonSteerableTurnKind"; * When an upstream HTTP status is available (for example, from the Responses API or a provider), * it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant. */ -export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "serverOverloaded" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other"; +export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "serverOverloaded" | "cyberPolicy" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts index 097cfdfecc..659974feaf 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { CommandExecTerminalSize } from "./CommandExecTerminalSize"; +import type { PermissionProfile } from "./PermissionProfile"; import type { SandboxPolicy } from "./SandboxPolicy"; /** @@ -92,6 +93,14 @@ size?: CommandExecTerminalSize | null, * Optional sandbox policy for this command. * * Uses the same shape as thread/turn execution sandbox configuration and - * defaults to the user's configured policy when omitted. + * defaults to the user's configured policy when omitted. Cannot be + * combined with `permissionProfile`. */ -sandboxPolicy?: SandboxPolicy | null, }; +sandboxPolicy?: SandboxPolicy | null, +/** + * Optional full permissions profile for this command. + * + * Defaults to the user's configured permissions when omitted. Cannot be + * combined with `sandboxPolicy`. + */ +permissionProfile?: PermissionProfile | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts new file mode 100644 index 0000000000..a81ce61f6e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ConfiguredHookHandler = { "type": "command", command: string, timeoutSec: bigint | null, async: boolean, statusMessage: string | null, } | { "type": "prompt", } | { "type": "agent", }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookMatcherGroup.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookMatcherGroup.ts new file mode 100644 index 0000000000..2c00fc166c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookMatcherGroup.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ConfiguredHookHandler } from "./ConfiguredHookHandler"; + +export type ConfiguredHookMatcherGroup = { matcher: string | null, hooks: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FileSystemSpecialPath.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FileSystemSpecialPath.ts index bf27547ee7..f4dc2b01e6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/FileSystemSpecialPath.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FileSystemSpecialPath.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type FileSystemSpecialPath = { "kind": "root" } | { "kind": "minimal" } | { "kind": "current_working_directory" } | { "kind": "project_roots", subpath: string | null, } | { "kind": "tmpdir" } | { "kind": "slash_tmp" } | { "kind": "unknown", path: string, subpath: string | null, }; +export type FileSystemSpecialPath = { "kind": "root" } | { "kind": "minimal" } | { "kind": "project_roots", subpath: string | null, } | { "kind": "tmpdir" } | { "kind": "slash_tmp" } | { "kind": "unknown", path: string, subpath: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GuardianWarningNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GuardianWarningNotification.ts new file mode 100644 index 0000000000..1659f62f1b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GuardianWarningNotification.ts @@ -0,0 +1,13 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type GuardianWarningNotification = { +/** + * Thread target for the guardian warning. + */ +threadId: string, +/** + * Concise guardian warning message for the user. + */ +message: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts new file mode 100644 index 0000000000..3386d16ec3 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup"; + +export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array, PermissionRequest: Array, PostToolUse: Array, SessionStart: Array, UserPromptSubmit: Array, Stop: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/MarketplaceUpgradeErrorInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/MarketplaceUpgradeErrorInfo.ts new file mode 100644 index 0000000000..d54f8f592e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/MarketplaceUpgradeErrorInfo.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type MarketplaceUpgradeErrorInfo = { marketplaceName: string, message: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/MarketplaceUpgradeParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/MarketplaceUpgradeParams.ts new file mode 100644 index 0000000000..6d2e5f50bd --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/MarketplaceUpgradeParams.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type MarketplaceUpgradeParams = { marketplaceName?: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/MarketplaceUpgradeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/MarketplaceUpgradeResponse.ts new file mode 100644 index 0000000000..456fbdccab --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/MarketplaceUpgradeResponse.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { MarketplaceUpgradeErrorInfo } from "./MarketplaceUpgradeErrorInfo"; + +export type MarketplaceUpgradeResponse = { selectedMarketplaces: Array, upgradedRoots: Array, errors: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ModelVerification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ModelVerification.ts new file mode 100644 index 0000000000..00538c090f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ModelVerification.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ModelVerification = "trustedAccessForCyber"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ModelVerificationNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ModelVerificationNotification.ts new file mode 100644 index 0000000000..3af484d0ea --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ModelVerificationNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ModelVerification } from "./ModelVerification"; + +export type ModelVerificationNotification = { threadId: string, turnId: string, verifications: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts new file mode 100644 index 0000000000..7642c27650 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PermissionProfileFileSystemPermissions } from "./PermissionProfileFileSystemPermissions"; +import type { PermissionProfileNetworkPermissions } from "./PermissionProfileNetworkPermissions"; + +export type PermissionProfile = { "type": "managed", network: PermissionProfileNetworkPermissions, fileSystem: PermissionProfileFileSystemPermissions, } | { "type": "disabled" } | { "type": "external", network: PermissionProfileNetworkPermissions, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileFileSystemPermissions.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileFileSystemPermissions.ts new file mode 100644 index 0000000000..29aeceb433 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileFileSystemPermissions.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FileSystemSandboxEntry } from "./FileSystemSandboxEntry"; + +export type PermissionProfileFileSystemPermissions = { "type": "restricted", entries: Array, globScanMaxDepth?: number, } | { "type": "unrestricted" }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileNetworkPermissions.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileNetworkPermissions.ts new file mode 100644 index 0000000000..0b25a769a9 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileNetworkPermissions.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PermissionProfileNetworkPermissions = { enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalResponse.ts index 6561417b40..f42b39560c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalResponse.ts @@ -4,4 +4,8 @@ import type { GrantedPermissionProfile } from "./GrantedPermissionProfile"; import type { PermissionGrantScope } from "./PermissionGrantScope"; -export type PermissionsRequestApprovalResponse = { permissions: GrantedPermissionProfile, scope: PermissionGrantScope, }; +export type PermissionsRequestApprovalResponse = { permissions: GrantedPermissionProfile, scope: PermissionGrantScope, +/** + * Review every subsequent command in this turn before normal sandboxed execution. + */ +strictAutoReview?: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts index 4bfd35fe70..eb0f38caa6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts @@ -6,4 +6,4 @@ import type { AppSummary } from "./AppSummary"; import type { PluginSummary } from "./PluginSummary"; import type { SkillSummary } from "./SkillSummary"; -export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf, summary: PluginSummary, description: string | null, skills: Array, apps: Array, mcpServers: Array, }; +export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array, apps: Array, mcpServers: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts index c6780648cf..5575701ff2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts @@ -3,6 +3,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { NetworkAccess } from "./NetworkAccess"; -import type { ReadOnlyAccess } from "./ReadOnlyAccess"; -export type SandboxPolicy = { "type": "dangerFullAccess" } | { "type": "readOnly", access: ReadOnlyAccess, networkAccess: boolean, } | { "type": "externalSandbox", networkAccess: NetworkAccess, } | { "type": "workspaceWrite", writableRoots: Array, readOnlyAccess: ReadOnlyAccess, networkAccess: boolean, excludeTmpdirEnvVar: boolean, excludeSlashTmp: boolean, }; +export type SandboxPolicy = { "type": "dangerFullAccess" } | { "type": "readOnly", networkAccess: boolean, } | { "type": "externalSandbox", networkAccess: NetworkAccess, } | { "type": "workspaceWrite", writableRoots: Array, networkAccess: boolean, excludeTmpdirEnvVar: boolean, excludeSlashTmp: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts index 05aa4031a8..4999a0728a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts @@ -4,4 +4,4 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { SkillInterface } from "./SkillInterface"; -export type SkillSummary = { name: string, description: string, shortDescription: string | null, interface: SkillInterface | null, path: AbsolutePathBuf, enabled: boolean, }; +export type SkillSummary = { name: string, description: string, shortDescription: string | null, interface: SkillInterface | null, path: AbsolutePathBuf | null, enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadApproveGuardianDeniedActionParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadApproveGuardianDeniedActionParams.ts new file mode 100644 index 0000000000..7d1ab0d83f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadApproveGuardianDeniedActionParams.ts @@ -0,0 +1,10 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "../serde_json/JsonValue"; + +export type ThreadApproveGuardianDeniedActionParams = { threadId: string, +/** + * Serialized `codex_protocol::protocol::GuardianAssessmentEvent`. + */ +event: JsonValue, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadApproveGuardianDeniedActionResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadApproveGuardianDeniedActionResponse.ts new file mode 100644 index 0000000000..856bb28cfb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadApproveGuardianDeniedActionResponse.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadApproveGuardianDeniedActionResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts index a7ba311803..f5f3f1878c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts @@ -5,6 +5,7 @@ import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; +import type { PermissionProfile } from "./PermissionProfile"; import type { SandboxMode } from "./SandboxMode"; /** @@ -27,7 +28,16 @@ model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier * Override where approval requests are routed for review on this thread * and subsequent turns. */ -approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean, /** +approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, /** + * Full permissions override for the forked thread. Cannot be combined + * with `sandbox`. + */ +permissionProfile?: PermissionProfile | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean, /** + * When true, return only thread metadata and live fork state without + * populating `thread.turns`. This is useful when the client plans to call + * `thread/turns/list` immediately after forking. + */ +excludeTurns?: boolean, /** * If true, persist additional rollout EventMsg variants required to * reconstruct a richer thread history on subsequent resume/fork/read. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts index 470e98c9b8..b69f1da012 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts @@ -6,6 +6,7 @@ import type { ReasoningEffort } from "../ReasoningEffort"; import type { ServiceTier } from "../ServiceTier"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; +import type { PermissionProfile } from "./PermissionProfile"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; @@ -17,4 +18,14 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** * Reviewer currently used for approval requests on this thread. */ -approvalsReviewer: ApprovalsReviewer, sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null, }; +approvalsReviewer: ApprovalsReviewer, +/** + * Legacy sandbox policy retained for compatibility. New clients should use + * `permissionProfile` when present as the canonical active permissions + * view. + */ +sandbox: SandboxPolicy, +/** + * Canonical active permissions view for this thread. + */ +permissionProfile: PermissionProfile | null, reasoningEffort: ReasoningEffort | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoal.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoal.ts new file mode 100644 index 0000000000..c68732324f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoal.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ThreadGoalStatus } from "./ThreadGoalStatus"; + +export type ThreadGoal = { threadId: string, objective: string, status: ThreadGoalStatus, tokenBudget: number | null, tokensUsed: number, timeUsedSeconds: number, createdAt: number, updatedAt: number, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalClearedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalClearedNotification.ts new file mode 100644 index 0000000000..e8e5a8b6e0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalClearedNotification.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadGoalClearedNotification = { threadId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.ts new file mode 100644 index 0000000000..7a4bf332fb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadGoalStatus = "active" | "paused" | "budgetLimited" | "complete"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalUpdatedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalUpdatedNotification.ts new file mode 100644 index 0000000000..c9972afa84 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalUpdatedNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ThreadGoal } from "./ThreadGoal"; + +export type ThreadGoalUpdatedNotification = { threadId: string, turnId: string | null, goal: ThreadGoal, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts index a2add17685..ce5b6a79ba 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts @@ -38,10 +38,16 @@ sourceKinds?: Array | null, */ archived?: boolean | null, /** - * Optional cwd filter; when set, only threads whose session cwd exactly - * matches this path are returned. + * Optional cwd filter or filters; when set, only threads whose session cwd + * exactly matches one of these paths are returned. */ -cwd?: string | null, +cwd?: string | Array | null, +/** + * If true, return from the state DB without scanning JSONL rollouts to + * repair thread metadata. Omitted or false preserves scan-and-repair + * behavior. + */ +useStateDbOnly?: boolean, /** * Optional substring filter for the extracted thread title. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts index 770344de8e..452126be46 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts @@ -7,6 +7,7 @@ import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; +import type { PermissionProfile } from "./PermissionProfile"; import type { SandboxMode } from "./SandboxMode"; /** @@ -36,7 +37,16 @@ model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier * Override where approval requests are routed for review on this thread * and subsequent turns. */ -approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, /** +approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, /** + * Full permissions override for the resumed thread. Cannot be combined + * with `sandbox`. + */ +permissionProfile?: PermissionProfile | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, /** + * When true, return only thread metadata and live-resume state without + * populating `thread.turns`. This is useful when the client plans to call + * `thread/turns/list` immediately after resuming. + */ +excludeTurns?: boolean, /** * If true, persist additional rollout EventMsg variants required to * reconstruct a richer thread history on subsequent resume/fork/read. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts index 177add8350..5ceec7f3fe 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts @@ -6,6 +6,7 @@ import type { ReasoningEffort } from "../ReasoningEffort"; import type { ServiceTier } from "../ServiceTier"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; +import type { PermissionProfile } from "./PermissionProfile"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; @@ -17,4 +18,14 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** * Reviewer currently used for approval requests on this thread. */ -approvalsReviewer: ApprovalsReviewer, sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null, }; +approvalsReviewer: ApprovalsReviewer, +/** + * Legacy sandbox policy retained for compatibility. New clients should use + * `permissionProfile` when present as the canonical active permissions + * view. + */ +sandbox: SandboxPolicy, +/** + * Canonical active permissions view for this thread. + */ +permissionProfile: PermissionProfile | null, reasoningEffort: ReasoningEffort | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts index 904487e81c..8b9dafec9f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts @@ -6,6 +6,7 @@ import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; +import type { PermissionProfile } from "./PermissionProfile"; import type { SandboxMode } from "./SandboxMode"; import type { ThreadStartSource } from "./ThreadStartSource"; @@ -13,7 +14,11 @@ export type ThreadStartParams = {model?: string | null, modelProvider?: string | * Override where approval requests are routed for review on this thread * and subsequent turns. */ -approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, sessionStartSource?: ThreadStartSource | null, /** +approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, /** + * Full permissions override for this thread. Cannot be combined with + * `sandbox`. + */ +permissionProfile?: PermissionProfile | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, sessionStartSource?: ThreadStartSource | null, /** * If true, opt into emitting raw Responses API items on the event stream. * This is for internal use only (e.g. Codex Cloud). */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts index fd84a41ae8..61d268afe8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts @@ -6,6 +6,7 @@ import type { ReasoningEffort } from "../ReasoningEffort"; import type { ServiceTier } from "../ServiceTier"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; +import type { PermissionProfile } from "./PermissionProfile"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; @@ -17,4 +18,14 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** * Reviewer currently used for approval requests on this thread. */ -approvalsReviewer: ApprovalsReviewer, sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null, }; +approvalsReviewer: ApprovalsReviewer, +/** + * Legacy sandbox policy retained for compatibility. New clients should use + * `permissionProfile` when present as the canonical active permissions + * view. + */ +sandbox: SandboxPolicy, +/** + * Canonical active permissions view for this thread. + */ +permissionProfile: PermissionProfile | null, reasoningEffort: ReasoningEffort | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ReadOnlyAccess.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts similarity index 57% rename from codex-rs/app-server-protocol/schema/typescript/v2/ReadOnlyAccess.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts index 78fa04ff37..bb981b0ac9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ReadOnlyAccess.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; -export type ReadOnlyAccess = { "type": "restricted", includePlatformDefaults: boolean, readableRoots: Array, } | { "type": "fullAccess" }; +export type TurnEnvironmentParams = { environmentId: string, cwd: AbsolutePathBuf, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..3d12e6001c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -9,6 +9,7 @@ import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; +import type { PermissionProfile } from "./PermissionProfile"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { UserInput } from "./UserInput"; @@ -26,6 +27,10 @@ approvalsReviewer?: ApprovalsReviewer | null, /** * Override the sandbox policy for this turn and subsequent turns. */ sandboxPolicy?: SandboxPolicy | null, /** + * Override the full permissions profile for this turn and subsequent + * turns. Cannot be combined with `sandboxPolicy`. + */ +permissionProfile?: PermissionProfile | null, /** * Override the model for this turn and subsequent turns. */ model?: string | null, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 7bddd0f9d6..0e43b5a4b7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -71,6 +71,8 @@ export type { ConfigRequirementsReadResponse } from "./ConfigRequirementsReadRes export type { ConfigValueWriteParams } from "./ConfigValueWriteParams"; export type { ConfigWarningNotification } from "./ConfigWarningNotification"; export type { ConfigWriteResponse } from "./ConfigWriteResponse"; +export type { ConfiguredHookHandler } from "./ConfiguredHookHandler"; +export type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup"; export type { ContextCompactedNotification } from "./ContextCompactedNotification"; export type { CreditsSnapshot } from "./CreditsSnapshot"; export type { DeprecationNoticeNotification } from "./DeprecationNoticeNotification"; @@ -147,6 +149,7 @@ export type { GuardianApprovalReviewStatus } from "./GuardianApprovalReviewStatu export type { GuardianCommandSource } from "./GuardianCommandSource"; export type { GuardianRiskLevel } from "./GuardianRiskLevel"; export type { GuardianUserAuthorization } from "./GuardianUserAuthorization"; +export type { GuardianWarningNotification } from "./GuardianWarningNotification"; export type { HookCompletedNotification } from "./HookCompletedNotification"; export type { HookEventName } from "./HookEventName"; export type { HookExecutionMode } from "./HookExecutionMode"; @@ -168,12 +171,16 @@ export type { ListMcpServerStatusResponse } from "./ListMcpServerStatusResponse" export type { LoginAccountParams } from "./LoginAccountParams"; export type { LoginAccountResponse } from "./LoginAccountResponse"; export type { LogoutAccountResponse } from "./LogoutAccountResponse"; +export type { ManagedHooksRequirements } from "./ManagedHooksRequirements"; export type { MarketplaceAddParams } from "./MarketplaceAddParams"; export type { MarketplaceAddResponse } from "./MarketplaceAddResponse"; export type { MarketplaceInterface } from "./MarketplaceInterface"; export type { MarketplaceLoadErrorInfo } from "./MarketplaceLoadErrorInfo"; export type { MarketplaceRemoveParams } from "./MarketplaceRemoveParams"; export type { MarketplaceRemoveResponse } from "./MarketplaceRemoveResponse"; +export type { MarketplaceUpgradeErrorInfo } from "./MarketplaceUpgradeErrorInfo"; +export type { MarketplaceUpgradeParams } from "./MarketplaceUpgradeParams"; +export type { MarketplaceUpgradeResponse } from "./MarketplaceUpgradeResponse"; export type { McpAuthStatus } from "./McpAuthStatus"; export type { McpElicitationArrayType } from "./McpElicitationArrayType"; export type { McpElicitationBooleanSchema } from "./McpElicitationBooleanSchema"; @@ -227,6 +234,8 @@ export type { ModelListResponse } from "./ModelListResponse"; export type { ModelRerouteReason } from "./ModelRerouteReason"; export type { ModelReroutedNotification } from "./ModelReroutedNotification"; export type { ModelUpgradeInfo } from "./ModelUpgradeInfo"; +export type { ModelVerification } from "./ModelVerification"; +export type { ModelVerificationNotification } from "./ModelVerificationNotification"; export type { NetworkAccess } from "./NetworkAccess"; export type { NetworkApprovalContext } from "./NetworkApprovalContext"; export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; @@ -240,6 +249,9 @@ export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; export type { PatchChangeKind } from "./PatchChangeKind"; export type { PermissionGrantScope } from "./PermissionGrantScope"; +export type { PermissionProfile } from "./PermissionProfile"; +export type { PermissionProfileFileSystemPermissions } from "./PermissionProfileFileSystemPermissions"; +export type { PermissionProfileNetworkPermissions } from "./PermissionProfileNetworkPermissions"; export type { PermissionsRequestApprovalParams } from "./PermissionsRequestApprovalParams"; export type { PermissionsRequestApprovalResponse } from "./PermissionsRequestApprovalResponse"; export type { PlanDeltaNotification } from "./PlanDeltaNotification"; @@ -264,7 +276,6 @@ export type { RateLimitReachedType } from "./RateLimitReachedType"; export type { RateLimitSnapshot } from "./RateLimitSnapshot"; export type { RateLimitWindow } from "./RateLimitWindow"; export type { RawResponseItemCompletedNotification } from "./RawResponseItemCompletedNotification"; -export type { ReadOnlyAccess } from "./ReadOnlyAccess"; export type { ReasoningEffortOption } from "./ReasoningEffortOption"; export type { ReasoningSummaryPartAddedNotification } from "./ReasoningSummaryPartAddedNotification"; export type { ReasoningSummaryTextDeltaNotification } from "./ReasoningSummaryTextDeltaNotification"; @@ -305,6 +316,8 @@ export type { TextPosition } from "./TextPosition"; export type { TextRange } from "./TextRange"; export type { Thread } from "./Thread"; export type { ThreadActiveFlag } from "./ThreadActiveFlag"; +export type { ThreadApproveGuardianDeniedActionParams } from "./ThreadApproveGuardianDeniedActionParams"; +export type { ThreadApproveGuardianDeniedActionResponse } from "./ThreadApproveGuardianDeniedActionResponse"; export type { ThreadArchiveParams } from "./ThreadArchiveParams"; export type { ThreadArchiveResponse } from "./ThreadArchiveResponse"; export type { ThreadArchivedNotification } from "./ThreadArchivedNotification"; @@ -313,6 +326,10 @@ export type { ThreadCompactStartParams } from "./ThreadCompactStartParams"; export type { ThreadCompactStartResponse } from "./ThreadCompactStartResponse"; export type { ThreadForkParams } from "./ThreadForkParams"; export type { ThreadForkResponse } from "./ThreadForkResponse"; +export type { ThreadGoal } from "./ThreadGoal"; +export type { ThreadGoalClearedNotification } from "./ThreadGoalClearedNotification"; +export type { ThreadGoalStatus } from "./ThreadGoalStatus"; +export type { ThreadGoalUpdatedNotification } from "./ThreadGoalUpdatedNotification"; export type { ThreadInjectItemsParams } from "./ThreadInjectItemsParams"; export type { ThreadInjectItemsResponse } from "./ThreadInjectItemsResponse"; export type { ThreadItem } from "./ThreadItem"; @@ -372,6 +389,7 @@ export type { ToolsV2 } from "./ToolsV2"; export type { Turn } from "./Turn"; export type { TurnCompletedNotification } from "./TurnCompletedNotification"; export type { TurnDiffUpdatedNotification } from "./TurnDiffUpdatedNotification"; +export type { TurnEnvironmentParams } from "./TurnEnvironmentParams"; export type { TurnError } from "./TurnError"; export type { TurnInterruptParams } from "./TurnInterruptParams"; export type { TurnInterruptResponse } from "./TurnInterruptResponse"; diff --git a/codex-rs/app-server-protocol/src/experimental_api.rs b/codex-rs/app-server-protocol/src/experimental_api.rs index 63c3dafce3..af7a1efbe6 100644 --- a/codex-rs/app-server-protocol/src/experimental_api.rs +++ b/codex-rs/app-server-protocol/src/experimental_api.rs @@ -98,6 +98,13 @@ mod tests { inners: HashMap, } + #[allow(dead_code)] + #[derive(ExperimentalApi)] + struct ExperimentalFieldShape { + #[experimental("field/optionalCollection")] + optional_collection: Option>, + } + #[test] fn derive_supports_all_enum_variant_shapes() { assert_eq!( @@ -169,4 +176,20 @@ mod tests { None ); } + + #[test] + fn derive_marks_optional_experimental_fields_when_some() { + assert_eq!( + ExperimentalApiTrait::experimental_reason(&ExperimentalFieldShape { + optional_collection: Some(Vec::new()), + }), + Some("field/optionalCollection") + ); + assert_eq!( + ExperimentalApiTrait::experimental_reason(&ExperimentalFieldShape { + optional_collection: None, + }), + None + ); + } } diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 4179e18b55..016d6e16b8 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -30,6 +30,11 @@ pub enum AuthMode { #[ts(rename = "chatgptAuthTokens")] #[strum(serialize = "chatgptAuthTokens")] ChatgptAuthTokens, + /// Programmatic Codex auth backed by a registered Agent Identity. + #[serde(rename = "agentIdentity")] + #[ts(rename = "agentIdentity")] + #[strum(serialize = "agentIdentity")] + AgentIdentity, } macro_rules! experimental_reason_expr { @@ -280,6 +285,21 @@ client_request_definitions! { params: v2::ThreadSetNameParams, response: v2::ThreadSetNameResponse, }, + #[experimental("thread/goal/set")] + ThreadGoalSet => "thread/goal/set" { + params: v2::ThreadGoalSetParams, + response: v2::ThreadGoalSetResponse, + }, + #[experimental("thread/goal/get")] + ThreadGoalGet => "thread/goal/get" { + params: v2::ThreadGoalGetParams, + response: v2::ThreadGoalGetResponse, + }, + #[experimental("thread/goal/clear")] + ThreadGoalClear => "thread/goal/clear" { + params: v2::ThreadGoalClearParams, + response: v2::ThreadGoalClearResponse, + }, ThreadMetadataUpdate => "thread/metadata/update" { params: v2::ThreadMetadataUpdateParams, response: v2::ThreadMetadataUpdateResponse, @@ -306,6 +326,10 @@ client_request_definitions! { params: v2::ThreadShellCommandParams, response: v2::ThreadShellCommandResponse, }, + ThreadApproveGuardianDeniedAction => "thread/approveGuardianDeniedAction" { + params: v2::ThreadApproveGuardianDeniedActionParams, + response: v2::ThreadApproveGuardianDeniedActionResponse, + }, #[experimental("thread/backgroundTerminals/clean")] ThreadBackgroundTerminalsClean => "thread/backgroundTerminals/clean" { params: v2::ThreadBackgroundTerminalsCleanParams, @@ -348,6 +372,10 @@ client_request_definitions! { params: v2::MarketplaceRemoveParams, response: v2::MarketplaceRemoveResponse, }, + MarketplaceUpgrade => "marketplace/upgrade" { + params: v2::MarketplaceUpgradeParams, + response: v2::MarketplaceUpgradeResponse, + }, PluginList => "plugin/list" { params: v2::PluginListParams, response: v2::PluginListResponse, @@ -1014,6 +1042,10 @@ server_notification_definitions! { ThreadClosed => "thread/closed" (v2::ThreadClosedNotification), SkillsChanged => "skills/changed" (v2::SkillsChangedNotification), ThreadNameUpdated => "thread/name/updated" (v2::ThreadNameUpdatedNotification), + #[experimental("thread/goal/updated")] + ThreadGoalUpdated => "thread/goal/updated" (v2::ThreadGoalUpdatedNotification), + #[experimental("thread/goal/cleared")] + ThreadGoalCleared => "thread/goal/cleared" (v2::ThreadGoalClearedNotification), ThreadTokenUsageUpdated => "thread/tokenUsage/updated" (v2::ThreadTokenUsageUpdatedNotification), TurnStarted => "turn/started" (v2::TurnStartedNotification), HookStarted => "hook/started" (v2::HookStartedNotification), @@ -1051,7 +1083,9 @@ server_notification_definitions! { /// Deprecated: Use `ContextCompaction` item type instead. ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification), ModelRerouted => "model/rerouted" (v2::ModelReroutedNotification), + ModelVerification => "model/verification" (v2::ModelVerificationNotification), Warning => "warning" (v2::WarningNotification), + GuardianWarning => "guardianWarning" (v2::GuardianWarningNotification), DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification), ConfigWarning => "configWarning" (v2::ConfigWarningNotification), FuzzyFileSearchSessionUpdated => "fuzzyFileSearch/sessionUpdated" (FuzzyFileSearchSessionUpdatedNotification), @@ -1430,6 +1464,7 @@ mod tests { #[test] fn serialize_client_response() -> Result<()> { + let cwd = absolute_path("/tmp"); let response = ClientResponse::ThreadStart { request_id: RequestId::Integer(7), response: v2::ThreadStartResponse { @@ -1443,7 +1478,7 @@ mod tests { updated_at: 2, status: v2::ThreadStatus::Idle, path: None, - cwd: absolute_path("/tmp"), + cwd: cwd.clone(), cli_version: "0.0.0".to_string(), source: v2::SessionSource::Exec, agent_nickname: None, @@ -1455,11 +1490,17 @@ mod tests { model: "gpt-5".to_string(), model_provider: "openai".to_string(), service_tier: None, - cwd: absolute_path("/tmp"), + cwd, instruction_sources: vec![absolute_path("/tmp/AGENTS.md")], approval_policy: v2::AskForApproval::OnFailure, approvals_reviewer: v2::ApprovalsReviewer::User, sandbox: v2::SandboxPolicy::DangerFullAccess, + permission_profile: Some( + codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy( + &codex_protocol::protocol::SandboxPolicy::DangerFullAccess, + ) + .into(), + ), reasoning_effort: None, }, }; @@ -1502,6 +1543,9 @@ mod tests { "sandbox": { "type": "dangerFullAccess" }, + "permissionProfile": { + "type": "disabled" + }, "reasoningEffort": null } }), @@ -2021,6 +2065,76 @@ mod tests { let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request); assert_eq!(reason, Some("thread/realtime/start")); } + + #[test] + fn thread_goal_methods_are_marked_experimental() { + let set_request = ClientRequest::ThreadGoalSet { + request_id: RequestId::Integer(1), + params: v2::ThreadGoalSetParams { + thread_id: "thr_123".to_string(), + objective: Some("ship goal mode".to_string()), + status: Some(v2::ThreadGoalStatus::Active), + token_budget: Some(Some(10_000)), + }, + }; + let get_request = ClientRequest::ThreadGoalGet { + request_id: RequestId::Integer(2), + params: v2::ThreadGoalGetParams { + thread_id: "thr_123".to_string(), + }, + }; + let clear_request = ClientRequest::ThreadGoalClear { + request_id: RequestId::Integer(3), + params: v2::ThreadGoalClearParams { + thread_id: "thr_123".to_string(), + }, + }; + + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&set_request), + Some("thread/goal/set") + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&get_request), + Some("thread/goal/get") + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&clear_request), + Some("thread/goal/clear") + ); + } + + #[test] + fn thread_goal_notifications_are_marked_experimental() { + let goal = v2::ThreadGoal { + thread_id: "thr_123".to_string(), + objective: "ship goal mode".to_string(), + status: v2::ThreadGoalStatus::Active, + token_budget: Some(10_000), + tokens_used: 123, + time_used_seconds: 45, + created_at: 1_700_000_000, + updated_at: 1_700_000_123, + }; + let updated = ServerNotification::ThreadGoalUpdated(v2::ThreadGoalUpdatedNotification { + thread_id: "thr_123".to_string(), + turn_id: None, + goal, + }); + let cleared = ServerNotification::ThreadGoalCleared(v2::ThreadGoalClearedNotification { + thread_id: "thr_123".to_string(), + }); + + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&updated), + Some("thread/goal/updated") + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&cleared), + Some("thread/goal/cleared") + ); + } + #[test] fn thread_realtime_started_notification_is_marked_experimental() { let notification = diff --git a/codex-rs/app-server-protocol/src/protocol/mappers.rs b/codex-rs/app-server-protocol/src/protocol/mappers.rs index 93f12691be..dae91e650f 100644 --- a/codex-rs/app-server-protocol/src/protocol/mappers.rs +++ b/codex-rs/app-server-protocol/src/protocol/mappers.rs @@ -18,6 +18,7 @@ impl From for v2::CommandExecParams { env: None, size: None, sandbox_policy: value.sandbox_policy.map(std::convert::Into::into), + permission_profile: None, } } } diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index f7973a45de..019c9fa83e 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1357,6 +1357,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -1431,6 +1432,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -1754,6 +1756,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2267,6 +2270,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2304,6 +2308,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2356,6 +2361,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2393,6 +2399,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2567,6 +2574,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2585,6 +2593,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2596,6 +2605,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2630,6 +2640,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2686,6 +2697,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -2931,6 +2943,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2990,6 +3003,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -3041,6 +3055,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -3081,7 +3096,6 @@ mod tests { content: vec![codex_protocol::models::ContentItem::InputText { text: "plain text".into(), }], - end_turn: None, phase: None, }), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { @@ -3089,6 +3103,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index b7162eb4de..e1c941019d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -7,6 +7,7 @@ use crate::RequestId; use crate::protocol::common::AuthMode; use codex_experimental_api_macros::ExperimentalApi; use codex_protocol::account::PlanType; +use codex_protocol::account::ProviderAccount; use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest; use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; use codex_protocol::approvals::GuardianAssessmentAction as CoreGuardianAssessmentAction; @@ -37,7 +38,9 @@ use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate; use codex_protocol::mcp::Tool as McpTool; use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry; +use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; +use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; use codex_protocol::models::MessagePhase; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; use codex_protocol::models::PermissionProfile as CorePermissionProfile; @@ -51,6 +54,7 @@ use codex_protocol::permissions::FileSystemAccessMode as CoreFileSystemAccessMod use codex_protocol::permissions::FileSystemPath as CoreFileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry as CoreFileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSpecialPath as CoreFileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy as CoreNetworkSandboxPolicy; use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg; use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus; use codex_protocol::protocol::AgentStatus as CoreAgentStatus; @@ -72,13 +76,13 @@ use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary; use codex_protocol::protocol::HookScope as CoreHookScope; use codex_protocol::protocol::HookSource as CoreHookSource; use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason; +use codex_protocol::protocol::ModelVerification as CoreModelVerification; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind; use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; use codex_protocol::protocol::RateLimitReachedType as CoreRateLimitReachedType; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; -use codex_protocol::protocol::ReadOnlyAccess as CoreReadOnlyAccess; use codex_protocol::protocol::RealtimeAudioFrame as CoreRealtimeAudioFrame; use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::RealtimeOutputModality; @@ -92,6 +96,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::ThreadGoalStatus as CoreThreadGoalStatus; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -101,6 +106,11 @@ use codex_protocol::user_input::TextElement as CoreTextElement; use codex_protocol::user_input::UserInput as CoreUserInput; use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::InstanceType; +use schemars::schema::Metadata; +use schemars::schema::Schema; +use schemars::schema::SchemaObject; use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; @@ -158,6 +168,7 @@ pub enum CodexErrorInfo { ContextWindowExceeded, UsageLimitExceeded, ServerOverloaded, + CyberPolicy, HttpConnectionFailed { #[serde(rename = "httpStatusCode")] #[ts(rename = "httpStatusCode")] @@ -202,6 +213,7 @@ impl From for CodexErrorInfo { CoreCodexErrorInfo::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded, CoreCodexErrorInfo::UsageLimitExceeded => CodexErrorInfo::UsageLimitExceeded, CoreCodexErrorInfo::ServerOverloaded => CodexErrorInfo::ServerOverloaded, + CoreCodexErrorInfo::CyberPolicy => CodexErrorInfo::CyberPolicy, CoreCodexErrorInfo::HttpConnectionFailed { http_status_code } => { CodexErrorInfo::HttpConnectionFailed { http_status_code } } @@ -304,24 +316,59 @@ impl From for AskForApproval { } } -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case", export_to = "v2/")] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, TS)] +#[ts( + type = r#""user" | "auto_review" | "guardian_subagent""#, + export_to = "v2/" +)] /// Configures who approval requests are routed to for review. Examples /// include sandbox escapes, blocked network access, MCP approval prompts, and -/// ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully +/// ARC escalations. Defaults to `user`. `auto_review` uses a carefully /// prompted subagent to gather relevant context and apply a risk-based /// decision framework before approving or denying the request. pub enum ApprovalsReviewer { + #[serde(rename = "user")] User, - GuardianSubagent, + #[serde(rename = "guardian_subagent", alias = "auto_review")] + AutoReview, +} + +impl JsonSchema for ApprovalsReviewer { + fn schema_name() -> String { + "ApprovalsReviewer".to_string() + } + + fn json_schema(_generator: &mut SchemaGenerator) -> Schema { + string_enum_schema_with_description( + &["user", "auto_review", "guardian_subagent"], + "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + ) + } +} + +fn string_enum_schema_with_description(values: &[&str], description: &str) -> Schema { + let mut schema = SchemaObject { + instance_type: Some(InstanceType::String.into()), + metadata: Some(Box::new(Metadata { + description: Some(description.to_string()), + ..Default::default() + })), + ..Default::default() + }; + schema.enum_values = Some( + values + .iter() + .map(|value| JsonValue::String((*value).to_string())) + .collect(), + ); + Schema::Object(schema) } impl ApprovalsReviewer { pub fn to_core(self) -> CoreApprovalsReviewer { match self { ApprovalsReviewer::User => CoreApprovalsReviewer::User, - ApprovalsReviewer::GuardianSubagent => CoreApprovalsReviewer::GuardianSubagent, + ApprovalsReviewer::AutoReview => CoreApprovalsReviewer::AutoReview, } } } @@ -330,7 +377,7 @@ impl From for ApprovalsReviewer { fn from(value: CoreApprovalsReviewer) -> Self { match value { CoreApprovalsReviewer::User => ApprovalsReviewer::User, - CoreApprovalsReviewer::GuardianSubagent => ApprovalsReviewer::GuardianSubagent, + CoreApprovalsReviewer::AutoReview => ApprovalsReviewer::AutoReview, } } } @@ -385,6 +432,12 @@ v2_enum_from_core!( } ); +v2_enum_from_core!( + pub enum ModelVerification from CoreModelVerification { + TrustedAccessForCyber + } +); + v2_enum_from_core!( pub enum HookEventName from CoreHookEventName { PreToolUse, PermissionRequest, PostToolUse, SessionStart, UserPromptSubmit, Stop @@ -755,10 +808,6 @@ const fn default_enabled() -> bool { true } -const fn default_include_platform_defaults() -> bool { - true -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] #[serde(rename_all = "snake_case")] #[ts(export_to = "v2/")] @@ -901,11 +950,71 @@ pub struct ConfigRequirements { pub allowed_sandbox_modes: Option>, pub allowed_web_search_modes: Option>, pub feature_requirements: Option>, + #[experimental("configRequirements/read.hooks")] + pub hooks: Option, pub enforce_residency: Option, #[experimental("configRequirements/read.network")] pub network: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ManagedHooksRequirements { + pub managed_dir: Option, + pub windows_managed_dir: Option, + #[serde(rename = "PreToolUse")] + #[ts(rename = "PreToolUse")] + pub pre_tool_use: Vec, + #[serde(rename = "PermissionRequest")] + #[ts(rename = "PermissionRequest")] + pub permission_request: Vec, + #[serde(rename = "PostToolUse")] + #[ts(rename = "PostToolUse")] + pub post_tool_use: Vec, + #[serde(rename = "SessionStart")] + #[ts(rename = "SessionStart")] + pub session_start: Vec, + #[serde(rename = "UserPromptSubmit")] + #[ts(rename = "UserPromptSubmit")] + pub user_prompt_submit: Vec, + #[serde(rename = "Stop")] + #[ts(rename = "Stop")] + pub stop: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfiguredHookMatcherGroup { + pub matcher: Option, + pub hooks: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type")] +#[ts(tag = "type", export_to = "v2/")] +pub enum ConfiguredHookHandler { + #[serde(rename = "command")] + #[ts(rename = "command")] + Command { + command: String, + #[serde(rename = "timeoutSec")] + #[ts(rename = "timeoutSec")] + timeout_sec: Option, + r#async: bool, + #[serde(rename = "statusMessage")] + #[ts(rename = "statusMessage")] + status_message: Option, + }, + #[serde(rename = "prompt")] + #[ts(rename = "prompt")] + Prompt {}, + #[serde(rename = "agent")] + #[ts(rename = "agent")] + Agent {}, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -1164,7 +1273,9 @@ impl From for NetworkApprovalContext { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct AdditionalFileSystemPermissions { + /// This will be removed in favor of `entries`. pub read: Option>, + /// This will be removed in favor of `entries`. pub write: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1177,11 +1288,26 @@ pub struct AdditionalFileSystemPermissions { impl From for AdditionalFileSystemPermissions { fn from(value: CoreFileSystemPermissions) -> Self { if let Some((read, write)) = value.legacy_read_write_roots() { + let mut entries = Vec::with_capacity( + read.as_ref().map_or(0, Vec::len) + write.as_ref().map_or(0, Vec::len), + ); + if let Some(paths) = read.as_ref() { + entries.extend(paths.iter().map(|path| FileSystemSandboxEntry { + path: FileSystemPath::Path { path: path.clone() }, + access: FileSystemAccessMode::Read, + })); + } + if let Some(paths) = write.as_ref() { + entries.extend(paths.iter().map(|path| FileSystemSandboxEntry { + path: FileSystemPath::Path { path: path.clone() }, + access: FileSystemAccessMode::Write, + })); + } Self { read, write, glob_scan_max_depth: None, - entries: None, + entries: Some(entries), } } else { Self { @@ -1225,6 +1351,13 @@ pub struct AdditionalNetworkPermissions { pub enabled: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionProfileNetworkPermissions { + pub enabled: bool, +} + impl From for AdditionalNetworkPermissions { fn from(value: CoreNetworkPermissions) -> Self { Self { @@ -1241,6 +1374,24 @@ impl From for CoreNetworkPermissions { } } +impl From for PermissionProfileNetworkPermissions { + fn from(value: CoreNetworkSandboxPolicy) -> Self { + Self { + enabled: value.is_enabled(), + } + } +} + +impl From for CoreNetworkSandboxPolicy { + fn from(value: PermissionProfileNetworkPermissions) -> Self { + if value.enabled { + Self::Enabled + } else { + Self::Restricted + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)] @@ -1283,7 +1434,7 @@ v2_enum_from_core!( pub enum FileSystemSpecialPath { Root, Minimal, - CurrentWorkingDirectory, + #[serde(alias = "current_working_directory")] ProjectRoots { subpath: Option, }, @@ -1300,7 +1451,6 @@ impl From for FileSystemSpecialPath { match value { CoreFileSystemSpecialPath::Root => Self::Root, CoreFileSystemSpecialPath::Minimal => Self::Minimal, - CoreFileSystemSpecialPath::CurrentWorkingDirectory => Self::CurrentWorkingDirectory, CoreFileSystemSpecialPath::ProjectRoots { subpath } => Self::ProjectRoots { subpath }, CoreFileSystemSpecialPath::Tmpdir => Self::Tmpdir, CoreFileSystemSpecialPath::SlashTmp => Self::SlashTmp, @@ -1314,7 +1464,6 @@ impl From for CoreFileSystemSpecialPath { match value { FileSystemSpecialPath::Root => Self::Root, FileSystemSpecialPath::Minimal => Self::Minimal, - FileSystemSpecialPath::CurrentWorkingDirectory => Self::CurrentWorkingDirectory, FileSystemSpecialPath::ProjectRoots { subpath } => Self::ProjectRoots { subpath }, FileSystemSpecialPath::Tmpdir => Self::Tmpdir, FileSystemSpecialPath::SlashTmp => Self::SlashTmp, @@ -1383,16 +1532,127 @@ impl From for CoreFileSystemSandboxEntry { } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PermissionProfileFileSystemPermissions { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Restricted { + entries: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + glob_scan_max_depth: Option, + }, + Unrestricted, +} + +impl From for PermissionProfileFileSystemPermissions { + fn from(value: CoreManagedFileSystemPermissions) -> Self { + match value { + CoreManagedFileSystemPermissions::Restricted { + entries, + glob_scan_max_depth, + } => Self::Restricted { + entries: entries + .into_iter() + .map(FileSystemSandboxEntry::from) + .collect(), + glob_scan_max_depth, + }, + CoreManagedFileSystemPermissions::Unrestricted => Self::Unrestricted, + } + } +} + +impl From for CoreManagedFileSystemPermissions { + fn from(value: PermissionProfileFileSystemPermissions) -> Self { + match value { + PermissionProfileFileSystemPermissions::Restricted { + entries, + glob_scan_max_depth, + } => Self::Restricted { + entries: entries + .into_iter() + .map(CoreFileSystemSandboxEntry::from) + .collect(), + glob_scan_max_depth, + }, + PermissionProfileFileSystemPermissions::Unrestricted => Self::Unrestricted, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PermissionProfile { + /// Codex owns sandbox construction for this profile. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Managed { + network: PermissionProfileNetworkPermissions, + file_system: PermissionProfileFileSystemPermissions, + }, + /// Do not apply an outer sandbox. + Disabled, + /// Filesystem isolation is enforced by an external caller. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + External { + network: PermissionProfileNetworkPermissions, + }, +} + +impl From for PermissionProfile { + fn from(value: CorePermissionProfile) -> Self { + match value { + CorePermissionProfile::Managed { + file_system, + network, + } => Self::Managed { + network: network.into(), + file_system: file_system.into(), + }, + CorePermissionProfile::Disabled => Self::Disabled, + CorePermissionProfile::External { network } => Self::External { + network: network.into(), + }, + } + } +} + +impl From for CorePermissionProfile { + fn from(value: PermissionProfile) -> Self { + match value { + PermissionProfile::Managed { + file_system, + network, + } => Self::Managed { + file_system: file_system.into(), + network: network.into(), + }, + PermissionProfile::Disabled => Self::Disabled, + PermissionProfile::External { network } => Self::External { + network: network.into(), + }, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct AdditionalPermissionProfile { + /// Partial overlay used for per-command permission requests. pub network: Option, pub file_system: Option, } -impl From for AdditionalPermissionProfile { - fn from(value: CorePermissionProfile) -> Self { +impl From for AdditionalPermissionProfile { + fn from(value: CoreAdditionalPermissionProfile) -> Self { Self { network: value.network.map(AdditionalNetworkPermissions::from), file_system: value.file_system.map(AdditionalFileSystemPermissions::from), @@ -1400,7 +1660,7 @@ impl From for AdditionalPermissionProfile { } } -impl From for CorePermissionProfile { +impl From for CoreAdditionalPermissionProfile { fn from(value: AdditionalPermissionProfile) -> Self { Self { network: value.network.map(CoreNetworkPermissions::from), @@ -1421,7 +1681,7 @@ pub struct GrantedPermissionProfile { pub file_system: Option, } -impl From for CorePermissionProfile { +impl From for CoreAdditionalPermissionProfile { fn from(value: GrantedPermissionProfile) -> Self { Self { network: value.network.map(CoreNetworkPermissions::from), @@ -1453,54 +1713,7 @@ pub enum NetworkAccess { Enabled, } -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum ReadOnlyAccess { - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Restricted { - #[serde(default = "default_include_platform_defaults")] - include_platform_defaults: bool, - #[serde(default)] - readable_roots: Vec, - }, - #[default] - FullAccess, -} - -impl ReadOnlyAccess { - pub fn to_core(&self) -> CoreReadOnlyAccess { - match self { - ReadOnlyAccess::Restricted { - include_platform_defaults, - readable_roots, - } => CoreReadOnlyAccess::Restricted { - include_platform_defaults: *include_platform_defaults, - readable_roots: readable_roots.clone(), - }, - ReadOnlyAccess::FullAccess => CoreReadOnlyAccess::FullAccess, - } - } -} - -impl From for ReadOnlyAccess { - fn from(value: CoreReadOnlyAccess) -> Self { - match value { - CoreReadOnlyAccess::Restricted { - include_platform_defaults, - readable_roots, - } => ReadOnlyAccess::Restricted { - include_platform_defaults, - readable_roots, - }, - CoreReadOnlyAccess::FullAccess => ReadOnlyAccess::FullAccess, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[derive(Serialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type")] #[ts(export_to = "v2/")] @@ -1509,8 +1722,6 @@ pub enum SandboxPolicy { #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] ReadOnly { - #[serde(default)] - access: ReadOnlyAccess, #[serde(default)] network_access: bool, }, @@ -1526,7 +1737,36 @@ pub enum SandboxPolicy { #[serde(default)] writable_roots: Vec, #[serde(default)] - read_only_access: ReadOnlyAccess, + network_access: bool, + #[serde(default)] + exclude_tmpdir_env_var: bool, + #[serde(default)] + exclude_slash_tmp: bool, + }, +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +enum SandboxPolicyDeserialize { + DangerFullAccess, + #[serde(rename_all = "camelCase")] + ReadOnly { + #[serde(default)] + network_access: bool, + #[serde(default)] + access: Option, + }, + #[serde(rename_all = "camelCase")] + ExternalSandbox { + #[serde(default)] + network_access: NetworkAccess, + }, + #[serde(rename_all = "camelCase")] + WorkspaceWrite { + #[serde(default)] + writable_roots: Vec, + #[serde(default)] + read_only_access: Option, #[serde(default)] network_access: bool, #[serde(default)] @@ -1536,19 +1776,68 @@ pub enum SandboxPolicy { }, } +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +enum LegacyReadOnlyAccess { + FullAccess, + Restricted, +} + +impl<'de> Deserialize<'de> for SandboxPolicy { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + match SandboxPolicyDeserialize::deserialize(deserializer)? { + SandboxPolicyDeserialize::DangerFullAccess => Ok(SandboxPolicy::DangerFullAccess), + SandboxPolicyDeserialize::ReadOnly { + network_access, + access, + } => { + if matches!(access, Some(LegacyReadOnlyAccess::Restricted)) { + return Err(serde::de::Error::custom( + "readOnly.access is no longer supported; use permissionProfile for restricted reads", + )); + } + Ok(SandboxPolicy::ReadOnly { network_access }) + } + SandboxPolicyDeserialize::ExternalSandbox { network_access } => { + Ok(SandboxPolicy::ExternalSandbox { network_access }) + } + SandboxPolicyDeserialize::WorkspaceWrite { + writable_roots, + read_only_access, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + } => { + if matches!(read_only_access, Some(LegacyReadOnlyAccess::Restricted)) { + return Err(serde::de::Error::custom( + "workspaceWrite.readOnlyAccess is no longer supported; use permissionProfile for restricted reads", + )); + } + Ok(SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + }) + } + } + } +} + impl SandboxPolicy { pub fn to_core(&self) -> codex_protocol::protocol::SandboxPolicy { match self { SandboxPolicy::DangerFullAccess => { codex_protocol::protocol::SandboxPolicy::DangerFullAccess } - SandboxPolicy::ReadOnly { - access, - network_access, - } => codex_protocol::protocol::SandboxPolicy::ReadOnly { - access: access.to_core(), - network_access: *network_access, - }, + SandboxPolicy::ReadOnly { network_access } => { + codex_protocol::protocol::SandboxPolicy::ReadOnly { + network_access: *network_access, + } + } SandboxPolicy::ExternalSandbox { network_access } => { codex_protocol::protocol::SandboxPolicy::ExternalSandbox { network_access: match network_access { @@ -1559,13 +1848,11 @@ impl SandboxPolicy { } SandboxPolicy::WorkspaceWrite { writable_roots, - read_only_access, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, } => codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { writable_roots: writable_roots.clone(), - read_only_access: read_only_access.to_core(), network_access: *network_access, exclude_tmpdir_env_var: *exclude_tmpdir_env_var, exclude_slash_tmp: *exclude_slash_tmp, @@ -1580,13 +1867,9 @@ impl From for SandboxPolicy { codex_protocol::protocol::SandboxPolicy::DangerFullAccess => { SandboxPolicy::DangerFullAccess } - codex_protocol::protocol::SandboxPolicy::ReadOnly { - access, - network_access, - } => SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::from(access), - network_access, - }, + codex_protocol::protocol::SandboxPolicy::ReadOnly { network_access } => { + SandboxPolicy::ReadOnly { network_access } + } codex_protocol::protocol::SandboxPolicy::ExternalSandbox { network_access } => { SandboxPolicy::ExternalSandbox { network_access: match network_access { @@ -1597,13 +1880,11 @@ impl From for SandboxPolicy { } codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { writable_roots, - read_only_access, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, } => SandboxPolicy::WorkspaceWrite { writable_roots, - read_only_access: ReadOnlyAccess::from(read_only_access), network_access, exclude_tmpdir_env_var, exclude_slash_tmp, @@ -1802,6 +2083,20 @@ pub enum Account { #[serde(rename = "chatgpt", rename_all = "camelCase")] #[ts(rename = "chatgpt", rename_all = "camelCase")] Chatgpt { email: String, plan_type: PlanType }, + + #[serde(rename = "amazonBedrock", rename_all = "camelCase")] + #[ts(rename = "amazonBedrock", rename_all = "camelCase")] + AmazonBedrock {}, +} + +impl From for Account { + fn from(account: ProviderAccount) -> Self { + match account { + ProviderAccount::ApiKey => Self::ApiKey {}, + ProviderAccount::Chatgpt { email, plan_type } => Self::Chatgpt { email, plan_type }, + ProviderAccount::AmazonBedrock => Self::AmazonBedrock {}, + } + } } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] @@ -2939,9 +3234,16 @@ pub struct CommandExecParams { /// Optional sandbox policy for this command. /// /// Uses the same shape as thread/turn execution sandbox configuration and - /// defaults to the user's configured policy when omitted. + /// defaults to the user's configured policy when omitted. Cannot be + /// combined with `permissionProfile`. #[ts(optional = nullable)] pub sandbox_policy: Option, + /// Optional full permissions profile for this command. + /// + /// Defaults to the user's configured permissions when omitted. Cannot be + /// combined with `sandboxPolicy`. + #[ts(optional = nullable)] + pub permission_profile: Option, } /// Final buffered result for `command/exec`. @@ -3060,6 +3362,10 @@ pub struct ThreadStartParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, + /// Full permissions override for this thread. Cannot be combined with + /// `sandbox`. + #[ts(optional = nullable)] + pub permission_profile: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] @@ -3074,6 +3380,15 @@ pub struct ThreadStartParams { pub ephemeral: Option, #[ts(optional = nullable)] pub session_start_source: Option, + /// Optional sticky environments for this thread. + /// + /// Omitted selects the default environment when environment access is + /// enabled. Empty disables environment access for turns that do not + /// provide a turn override. Non-empty selects the first environment as the + /// current turn environment. + #[experimental("thread/start.environments")] + #[ts(optional = nullable)] + pub environments: Option>, #[experimental("thread/start.dynamicTools")] #[ts(optional = nullable)] pub dynamic_tools: Option>, @@ -3127,7 +3442,13 @@ pub struct ThreadStartResponse { pub approval_policy: AskForApproval, /// Reviewer currently used for approval requests on this thread. pub approvals_reviewer: ApprovalsReviewer, + /// Legacy sandbox policy retained for compatibility. New clients should use + /// `permissionProfile` when present as the canonical active permissions + /// view. pub sandbox: SandboxPolicy, + /// Canonical active permissions view for this thread. + #[serde(default)] + pub permission_profile: Option, pub reasoning_effort: Option, } @@ -3185,6 +3506,10 @@ pub struct ThreadResumeParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, + /// Full permissions override for the resumed thread. Cannot be combined + /// with `sandbox`. + #[ts(optional = nullable)] + pub permission_profile: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] @@ -3193,6 +3518,11 @@ pub struct ThreadResumeParams { pub developer_instructions: Option, #[ts(optional = nullable)] pub personality: Option, + /// When true, return only thread metadata and live-resume state without + /// populating `thread.turns`. This is useful when the client plans to call + /// `thread/turns/list` immediately after resuming. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub exclude_turns: bool, /// If true, persist additional rollout EventMsg variants required to /// reconstruct a richer thread history on subsequent resume/fork/read. #[experimental("thread/resume.persistFullHistory")] @@ -3216,7 +3546,13 @@ pub struct ThreadResumeResponse { pub approval_policy: AskForApproval, /// Reviewer currently used for approval requests on this thread. pub approvals_reviewer: ApprovalsReviewer, + /// Legacy sandbox policy retained for compatibility. New clients should use + /// `permissionProfile` when present as the canonical active permissions + /// view. pub sandbox: SandboxPolicy, + /// Canonical active permissions view for this thread. + #[serde(default)] + pub permission_profile: Option, pub reasoning_effort: Option, } @@ -3265,6 +3601,10 @@ pub struct ThreadForkParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, + /// Full permissions override for the forked thread. Cannot be combined + /// with `sandbox`. + #[ts(optional = nullable)] + pub permission_profile: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] @@ -3273,6 +3613,11 @@ pub struct ThreadForkParams { pub developer_instructions: Option, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub ephemeral: bool, + /// When true, return only thread metadata and live fork state without + /// populating `thread.turns`. This is useful when the client plans to call + /// `thread/turns/list` immediately after forking. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub exclude_turns: bool, /// If true, persist additional rollout EventMsg variants required to /// reconstruct a richer thread history on subsequent resume/fork/read. #[experimental("thread/fork.persistFullHistory")] @@ -3296,7 +3641,13 @@ pub struct ThreadForkResponse { pub approval_policy: AskForApproval, /// Reviewer currently used for approval requests on this thread. pub approvals_reviewer: ApprovalsReviewer, + /// Legacy sandbox policy retained for compatibility. New clients should use + /// `permissionProfile` when present as the canonical active permissions + /// view. pub sandbox: SandboxPolicy, + /// Canonical active permissions view for this thread. + #[serde(default)] + pub permission_profile: Option, pub reasoning_effort: Option, } @@ -3395,6 +3746,103 @@ pub struct ThreadUnarchiveParams { #[ts(export_to = "v2/")] pub struct ThreadSetNameResponse {} +v2_enum_from_core! { + pub enum ThreadGoalStatus from CoreThreadGoalStatus { + Active, + Paused, + BudgetLimited, + Complete, + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoal { + pub thread_id: String, + pub objective: String, + pub status: ThreadGoalStatus, + #[ts(type = "number | null")] + pub token_budget: Option, + #[ts(type = "number")] + pub tokens_used: i64, + #[ts(type = "number")] + pub time_used_seconds: i64, + #[ts(type = "number")] + pub created_at: i64, + #[ts(type = "number")] + pub updated_at: i64, +} + +impl From for ThreadGoal { + fn from(value: codex_protocol::protocol::ThreadGoal) -> Self { + Self { + thread_id: value.thread_id.to_string(), + objective: value.objective, + status: value.status.into(), + token_budget: value.token_budget, + tokens_used: value.tokens_used, + time_used_seconds: value.time_used_seconds, + created_at: value.created_at, + updated_at: value.updated_at, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalSetParams { + pub thread_id: String, + #[ts(optional = nullable)] + pub objective: Option, + #[ts(optional = nullable)] + pub status: Option, + #[serde( + default, + deserialize_with = "super::serde_helpers::deserialize_double_option", + serialize_with = "super::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable, type = "number | null")] + pub token_budget: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalSetResponse { + pub goal: ThreadGoal, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalGetParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalGetResponse { + pub goal: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalClearParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalClearResponse { + pub cleared: bool, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -3528,6 +3976,20 @@ pub struct ThreadShellCommandParams { #[ts(export_to = "v2/")] pub struct ThreadShellCommandResponse {} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadApproveGuardianDeniedActionParams { + pub thread_id: String, + /// Serialized `codex_protocol::protocol::GuardianAssessmentEvent`. + pub event: JsonValue, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadApproveGuardianDeniedActionResponse {} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -3592,15 +4054,27 @@ pub struct ThreadListParams { /// If false or null, only non-archived threads are returned. #[ts(optional = nullable)] pub archived: Option, - /// Optional cwd filter; when set, only threads whose session cwd exactly - /// matches this path are returned. - #[ts(optional = nullable)] - pub cwd: Option, + /// Optional cwd filter or filters; when set, only threads whose session cwd + /// exactly matches one of these paths are returned. + #[ts(optional = nullable, type = "string | Array | null")] + pub cwd: Option, + /// If true, return from the state DB without scanning JSONL rollouts to + /// repair thread metadata. Omitted or false preserves scan-and-repair + /// behavior. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub use_state_db_only: bool, /// Optional substring filter for the extracted thread title. #[ts(optional = nullable)] pub search_term: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(untagged)] +pub enum ThreadListCwdFilter { + One(String), + Many(Vec), +} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase", export_to = "v2/")] @@ -3812,6 +4286,31 @@ pub struct MarketplaceRemoveResponse { pub installed_root: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceUpgradeParams { + #[ts(optional = nullable)] + pub marketplace_name: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceUpgradeResponse { + pub selected_marketplaces: Vec, + pub upgraded_roots: Vec, + pub errors: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceUpgradeErrorInfo { + pub marketplace_name: String, + pub message: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -4018,7 +4517,7 @@ pub struct PluginSummary { #[ts(export_to = "v2/")] pub struct PluginDetail { pub marketplace_name: String, - pub marketplace_path: AbsolutePathBuf, + pub marketplace_path: Option, pub summary: PluginSummary, pub description: Option, pub skills: Vec, @@ -4034,7 +4533,7 @@ pub struct SkillSummary { pub description: String, pub short_description: Option, pub interface: Option, - pub path: AbsolutePathBuf, + pub path: Option, pub enabled: bool, } @@ -4641,6 +5140,14 @@ pub enum TurnStatus { } // Turn APIs +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnEnvironmentParams { + pub environment_id: String, + pub cwd: AbsolutePathBuf, +} + #[derive( Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, )] @@ -4653,6 +5160,14 @@ pub struct TurnStartParams { #[experimental("turn/start.responsesapiClientMetadata")] #[ts(optional = nullable)] pub responsesapi_client_metadata: Option>, + /// Optional turn-scoped environments. + /// + /// Omitted uses the thread sticky environments. Empty disables + /// environment access for this turn. Non-empty selects the first + /// environment as the current turn environment for this turn. + #[experimental("turn/start.environments")] + #[ts(optional = nullable)] + pub environments: Option>, /// Override the working directory for this turn and subsequent turns. #[ts(optional = nullable)] pub cwd: Option, @@ -4667,6 +5182,10 @@ pub struct TurnStartParams { /// Override the sandbox policy for this turn and subsequent turns. #[ts(optional = nullable)] pub sandbox_policy: Option, + /// Override the full permissions profile for this turn and subsequent + /// turns. Cannot be combined with `sandboxPolicy`. + #[ts(optional = nullable)] + pub permission_profile: Option, /// Override the model for this turn and subsequent turns. #[ts(optional = nullable)] pub model: Option, @@ -5847,6 +6366,22 @@ pub struct ThreadNameUpdatedNotification { pub thread_name: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalUpdatedNotification { + pub thread_id: String, + pub turn_id: Option, + pub goal: ThreadGoal, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalClearedNotification { + pub thread_id: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -6847,6 +7382,10 @@ pub struct PermissionsRequestApprovalResponse { pub permissions: GrantedPermissionProfile, #[serde(default)] pub scope: PermissionGrantScope, + /// Review every subsequent command in this turn before normal sandboxed execution. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub strict_auto_review: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -7081,6 +7620,15 @@ pub struct ModelReroutedNotification { pub reason: ModelRerouteReason, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelVerificationNotification { + pub thread_id: String, + pub turn_id: String, + pub verifications: Vec, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -7101,6 +7649,16 @@ pub struct WarningNotification { pub message: String, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianWarningNotification { + /// Thread target for the guardian warning. + pub thread_id: String, + /// Concise guardian warning message for the user. + pub message: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -7148,7 +7706,6 @@ mod tests { use codex_protocol::items::WebSearchItem; use codex_protocol::models::WebSearchAction as CoreWebSearchAction; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; - use codex_protocol::protocol::ReadOnlyAccess as CoreReadOnlyAccess; use codex_protocol::user_input::UserInput as CoreUserInput; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; @@ -7171,6 +7728,70 @@ mod tests { absolute_path("readable") } + #[test] + fn approvals_reviewer_serializes_auto_review_and_accepts_legacy_guardian_subagent() { + assert_eq!( + serde_json::to_string(&ApprovalsReviewer::User).expect("serialize reviewer"), + "\"user\"" + ); + assert_eq!( + serde_json::to_string(&ApprovalsReviewer::AutoReview).expect("serialize reviewer"), + "\"guardian_subagent\"" + ); + + for value in ["user", "auto_review", "guardian_subagent"] { + let json = format!("\"{value}\""); + let reviewer: ApprovalsReviewer = + serde_json::from_str(&json).expect("deserialize reviewer"); + let expected = if value == "user" { + ApprovalsReviewer::User + } else { + ApprovalsReviewer::AutoReview + }; + assert_eq!(expected, reviewer); + } + } + + #[test] + fn thread_list_params_accepts_single_cwd() { + let params = serde_json::from_value::(json!({ + "cwd": "/workspace", + })) + .expect("single cwd should deserialize"); + + assert_eq!( + params.cwd, + Some(ThreadListCwdFilter::One("/workspace".to_string())) + ); + assert!(!params.use_state_db_only); + } + + #[test] + fn thread_list_params_accepts_multiple_cwds() { + let params = serde_json::from_value::(json!({ + "cwd": ["/workspace", "/other-workspace"], + })) + .expect("cwd array should deserialize"); + + assert_eq!( + params.cwd, + Some(ThreadListCwdFilter::Many(vec![ + "/workspace".to_string(), + "/other-workspace".to_string(), + ])) + ); + } + + #[test] + fn thread_list_params_accepts_state_db_only_flag() { + let params = serde_json::from_value::(json!({ + "useStateDbOnly": true, + })) + .expect("state db only flag should deserialize"); + + assert!(params.use_state_db_only); + } + #[test] fn collab_agent_state_maps_interrupted_status() { assert_eq!( @@ -7396,6 +8017,45 @@ mod tests { ); } + #[test] + fn additional_file_system_permissions_populates_entries_for_legacy_roots() { + let read_only_path = absolute_path("read-only"); + let read_write_path = absolute_path("read-write"); + let core_permissions = CoreFileSystemPermissions::from_read_write_roots( + Some(vec![read_only_path.clone()]), + Some(vec![read_write_path.clone()]), + ); + + let permissions = AdditionalFileSystemPermissions::from(core_permissions.clone()); + + assert_eq!( + permissions, + AdditionalFileSystemPermissions { + read: Some(vec![read_only_path.clone()]), + write: Some(vec![read_write_path.clone()]), + glob_scan_max_depth: None, + entries: Some(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: read_only_path, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: read_write_path, + }, + access: FileSystemAccessMode::Write, + }, + ]), + } + ); + assert_eq!( + CoreFileSystemPermissions::from(permissions), + core_permissions + ); + } + #[test] fn additional_file_system_permissions_rejects_zero_glob_scan_depth() { serde_json::from_value::(json!({ @@ -7407,6 +8067,68 @@ mod tests { .expect_err("zero glob scan depth should fail deserialization"); } + #[test] + fn permission_profile_file_system_permissions_preserves_glob_scan_depth() { + let core_permissions = CoreManagedFileSystemPermissions::Restricted { + entries: vec![CoreFileSystemSandboxEntry { + path: CoreFileSystemPath::GlobPattern { + pattern: "**/*.env".to_string(), + }, + access: CoreFileSystemAccessMode::None, + }], + glob_scan_max_depth: NonZeroUsize::new(2), + }; + + let permissions = PermissionProfileFileSystemPermissions::from(core_permissions.clone()); + + assert_eq!( + permissions, + PermissionProfileFileSystemPermissions::Restricted { + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "**/*.env".to_string(), + }, + access: FileSystemAccessMode::None, + }], + glob_scan_max_depth: NonZeroUsize::new(2), + } + ); + assert_eq!( + CoreManagedFileSystemPermissions::from(permissions), + core_permissions + ); + } + + #[test] + fn permission_profile_file_system_permissions_rejects_zero_glob_scan_depth() { + serde_json::from_value::(json!({ + "type": "restricted", + "entries": [], + "globScanMaxDepth": 0, + })) + .expect_err("zero glob scan depth should fail deserialization"); + } + + #[test] + fn legacy_current_working_directory_special_path_deserializes_as_project_roots() { + let special_path = serde_json::from_value::(json!({ + "kind": "current_working_directory", + })) + .expect("legacy cwd special path should deserialize"); + + assert_eq!( + special_path, + FileSystemSpecialPath::ProjectRoots { subpath: None } + ); + assert_eq!( + serde_json::to_value(&special_path).expect("serialize special path"), + json!({ + "kind": "project_roots", + "subpath": null, + }) + ); + } + #[test] fn permissions_request_approval_response_uses_granted_permission_profile_without_macos() { let read_only_path = if cfg!(windows) { @@ -7454,8 +8176,8 @@ mod tests { ); assert_eq!( - CorePermissionProfile::from(response.permissions), - CorePermissionProfile { + CoreAdditionalPermissionProfile::from(response.permissions), + CoreAdditionalPermissionProfile { network: Some(CoreNetworkPermissions { enabled: Some(true), }), @@ -7481,6 +8203,18 @@ mod tests { .expect("response should deserialize"); assert_eq!(response.scope, PermissionGrantScope::Turn); + assert_eq!(response.strict_auto_review, None); + } + + #[test] + fn permissions_request_approval_response_accepts_strict_auto_review() { + let response = serde_json::from_value::(json!({ + "permissions": {}, + "strictAutoReview": true, + })) + .expect("response should deserialize"); + + assert_eq!(response.strict_auto_review, Some(true)); } #[test] @@ -7874,6 +8608,7 @@ mod tests { env: None, size: None, sandbox_policy: None, + permission_profile: None, } ); } @@ -7894,6 +8629,7 @@ mod tests { env: None, size: None, sandbox_policy: None, + permission_profile: None, }; let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); @@ -7908,6 +8644,7 @@ mod tests { "env": null, "size": null, "sandboxPolicy": null, + "permissionProfile": null, "outputBytesCap": null, }) ); @@ -7933,6 +8670,7 @@ mod tests { env: None, size: None, sandbox_policy: None, + permission_profile: None, }; let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); @@ -7949,6 +8687,7 @@ mod tests { "env": null, "size": null, "sandboxPolicy": null, + "permissionProfile": null, }) ); @@ -7977,6 +8716,7 @@ mod tests { ])), size: None, sandbox_policy: None, + permission_profile: None, }; let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); @@ -7995,6 +8735,7 @@ mod tests { }, "size": null, "sandboxPolicy": null, + "permissionProfile": null, }) ); @@ -8064,6 +8805,7 @@ mod tests { cols: 120, }), sandbox_policy: None, + permission_profile: None, }; let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); @@ -8082,6 +8824,7 @@ mod tests { "cols": 120, }, "sandboxPolicy": null, + "permissionProfile": null, }) ); @@ -8188,13 +8931,8 @@ mod tests { } #[test] - fn sandbox_policy_round_trips_read_only_access() { - let readable_root = test_absolute_path(); + fn sandbox_policy_round_trips_read_only_network_access() { let v2_policy = SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: vec![readable_root.clone()], - }, network_access: true, }; @@ -8202,10 +8940,6 @@ mod tests { assert_eq!( core_policy, codex_protocol::protocol::SandboxPolicy::ReadOnly { - access: CoreReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: vec![readable_root], - }, network_access: true, } ); @@ -8358,7 +9092,7 @@ mod tests { model_auto_compact_token_limit: None, model_provider: None, approval_policy: None, - approvals_reviewer: Some(ApprovalsReviewer::GuardianSubagent), + approvals_reviewer: Some(ApprovalsReviewer::AutoReview), sandbox_mode: None, sandbox_workspace_write: None, forced_chatgpt_workspace_id: None, @@ -8460,7 +9194,7 @@ mod tests { model: None, model_provider: None, approval_policy: None, - approvals_reviewer: Some(ApprovalsReviewer::GuardianSubagent), + approvals_reviewer: Some(ApprovalsReviewer::AutoReview), service_tier: None, model_reasoning_effort: None, model_reasoning_summary: None, @@ -8501,6 +9235,7 @@ mod tests { allowed_sandbox_modes: None, allowed_web_search_modes: None, feature_requirements: None, + hooks: None, enforce_residency: None, network: None, }); @@ -8828,14 +9563,9 @@ mod tests { } #[test] - fn sandbox_policy_round_trips_workspace_write_read_only_access() { - let readable_root = test_absolute_path(); + fn sandbox_policy_round_trips_workspace_write_access() { let v2_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: vec![readable_root.clone()], - }, network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -8846,10 +9576,6 @@ mod tests { core_policy, codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: CoreReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: vec![readable_root], - }, network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -8861,40 +9587,78 @@ mod tests { } #[test] - fn sandbox_policy_deserializes_legacy_read_only_without_access_field() { - let policy: SandboxPolicy = serde_json::from_value(json!({ - "type": "readOnly" + fn sandbox_policy_deserializes_legacy_read_only_full_access_field() { + let policy = serde_json::from_value::(json!({ + "type": "readOnly", + "access": { + "type": "fullAccess" + }, + "networkAccess": true })) - .expect("read-only policy should deserialize"); + .expect("read-only policy should ignore legacy fullAccess field"); assert_eq!( policy, SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, - network_access: false, + network_access: true } ); } #[test] - fn sandbox_policy_deserializes_legacy_workspace_write_without_read_only_access_field() { - let policy: SandboxPolicy = serde_json::from_value(json!({ + fn sandbox_policy_deserializes_legacy_workspace_write_full_access_field() { + let writable_root = absolute_path("/workspace"); + let policy = serde_json::from_value::(json!({ + "type": "workspaceWrite", + "writableRoots": [writable_root], + "readOnlyAccess": { + "type": "fullAccess" + }, + "networkAccess": true, + "excludeTmpdirEnvVar": true, + "excludeSlashTmp": true + })) + .expect("workspace-write policy should ignore legacy fullAccess field"); + assert_eq!( + policy, + SandboxPolicy::WorkspaceWrite { + writable_roots: vec![absolute_path("/workspace")], + network_access: true, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + } + ); + } + + #[test] + fn sandbox_policy_rejects_legacy_read_only_restricted_access_field() { + let err = serde_json::from_value::(json!({ + "type": "readOnly", + "access": { + "type": "restricted", + "includePlatformDefaults": false, + "readableRoots": [] + } + })) + .expect_err("read-only policy should reject removed restricted access field"); + assert!(err.to_string().contains("readOnly.access")); + } + + #[test] + fn sandbox_policy_rejects_legacy_workspace_write_restricted_read_access_field() { + let err = serde_json::from_value::(json!({ "type": "workspaceWrite", "writableRoots": [], + "readOnlyAccess": { + "type": "restricted", + "includePlatformDefaults": false, + "readableRoots": [] + }, "networkAccess": false, "excludeTmpdirEnvVar": false, "excludeSlashTmp": false })) - .expect("workspace-write policy should deserialize"); - assert_eq!( - policy, - SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - read_only_access: ReadOnlyAccess::FullAccess, - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - } - ); + .expect_err("workspace-write policy should reject removed restricted readOnlyAccess field"); + assert!(err.to_string().contains("workspaceWrite.readOnlyAccess")); } #[test] @@ -9293,6 +10057,36 @@ mod tests { ); } + #[test] + fn marketplace_upgrade_params_serialization_uses_optional_marketplace_name() { + assert_eq!( + serde_json::to_value(MarketplaceUpgradeParams { + marketplace_name: None, + }) + .unwrap(), + json!({ + "marketplaceName": null, + }), + ); + + assert_eq!( + serde_json::from_value::(json!({})).unwrap(), + MarketplaceUpgradeParams { + marketplace_name: None, + }, + ); + + assert_eq!( + serde_json::to_value(MarketplaceUpgradeParams { + marketplace_name: Some("debug".to_string()), + }) + .unwrap(), + json!({ + "marketplaceName": "debug", + }), + ); + } + #[test] fn plugin_marketplace_entry_serializes_remote_only_path_as_null() { assert_eq!( @@ -9539,6 +10333,37 @@ mod tests { ); } + #[test] + fn marketplace_upgrade_response_serializes_camel_case_fields() { + let upgraded_root = if cfg!(windows) { + r"C:\marketplaces\debug" + } else { + "/tmp/marketplaces/debug" + }; + let upgraded_root = AbsolutePathBuf::try_from(PathBuf::from(upgraded_root)).unwrap(); + let upgraded_root_json = upgraded_root.as_path().display().to_string(); + + assert_eq!( + serde_json::to_value(MarketplaceUpgradeResponse { + selected_marketplaces: vec!["debug".to_string()], + upgraded_roots: vec![upgraded_root], + errors: vec![MarketplaceUpgradeErrorInfo { + marketplace_name: "broken".to_string(), + message: "failed to clone".to_string(), + }], + }) + .unwrap(), + json!({ + "selectedMarketplaces": ["debug"], + "upgradedRoots": [upgraded_root_json], + "errors": [{ + "marketplaceName": "broken", + "message": "failed to clone", + }], + }), + ); + } + #[test] fn codex_error_info_serializes_http_status_code_in_camel_case() { let value = CodexErrorInfo::ResponseTooManyFailedAttempts { @@ -9555,6 +10380,14 @@ mod tests { ); } + #[test] + fn codex_error_info_serializes_cyber_policy_in_camel_case() { + assert_eq!( + serde_json::to_value(CodexErrorInfo::CyberPolicy).unwrap(), + json!("cyberPolicy") + ); + } + #[test] fn codex_error_info_serializes_active_turn_not_steerable_turn_kind_in_camel_case() { let value = CodexErrorInfo::ActiveTurnNotSteerable { @@ -9696,7 +10529,7 @@ mod tests { } #[test] - fn thread_lifecycle_responses_default_missing_instruction_sources() { + fn thread_lifecycle_responses_default_missing_compat_fields() { let response = json!({ "thread": { "id": "thread-id", @@ -9737,6 +10570,9 @@ mod tests { assert_eq!(start.instruction_sources, Vec::::new()); assert_eq!(resume.instruction_sources, Vec::::new()); assert_eq!(fork.instruction_sources, Vec::::new()); + assert_eq!(start.permission_profile, None); + assert_eq!(resume.permission_profile, None); + assert_eq!(fork.permission_profile, None); } #[test] @@ -9759,10 +10595,12 @@ mod tests { thread_id: "thread_123".to_string(), input: vec![], responsesapi_client_metadata: None, + environments: None, cwd: None, approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, model: None, service_tier: None, effort: None, @@ -9775,4 +10613,109 @@ mod tests { serde_json::to_value(&without_override).expect("params should serialize"); assert_eq!(serialized_without_override.get("serviceTier"), None); } + + #[test] + fn turn_start_params_round_trip_environments() { + let cwd = test_absolute_path(); + let params: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "environments": [ + { + "environmentId": "local", + "cwd": cwd + } + ], + })) + .expect("params should deserialize"); + + assert_eq!( + params.environments, + Some(vec![TurnEnvironmentParams { + environment_id: "local".to_string(), + cwd: cwd.clone(), + }]) + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¶ms), + Some("turn/start.environments") + ); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("environments"), + Some(&json!([ + { + "environmentId": "local", + "cwd": cwd + } + ])) + ); + } + + #[test] + fn turn_start_params_preserve_empty_environments() { + let params: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "environments": [], + })) + .expect("params should deserialize"); + + assert_eq!(params.environments, Some(Vec::new())); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¶ms), + Some("turn/start.environments") + ); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!(serialized.get("environments"), Some(&json!([]))); + } + + #[test] + fn turn_start_params_treat_null_or_omitted_environments_as_default() { + let null_environments: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "environments": null, + })) + .expect("params should deserialize"); + let omitted_environments: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + })) + .expect("params should deserialize"); + + assert_eq!(null_environments.environments, None); + assert_eq!(omitted_environments.environments, None); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&null_environments), + None + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&omitted_environments), + None + ); + } + + #[test] + fn turn_start_params_reject_relative_environment_cwd() { + let err = serde_json::from_value::(json!({ + "threadId": "thread_123", + "input": [], + "environments": [ + { + "environmentId": "local", + "cwd": "relative" + } + ], + })) + .expect_err("relative environment cwd should fail"); + + assert!( + err.to_string() + .contains("AbsolutePathBuf deserialized without a base path"), + "unexpected error: {err}" + ); + } } diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 6f6e0dfc83..2a3cea273b 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -48,7 +48,6 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; -use codex_app_server_protocol::ReadOnlyAccess; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -743,7 +742,6 @@ async fn trigger_zsh_fork_multi_cmd_approval( }; turn_params.approval_policy = Some(AskForApproval::OnRequest); turn_params.sandbox_policy = Some(SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, network_access: false, }); @@ -885,7 +883,6 @@ async fn trigger_cmd_approval( experimental_api: true, approval_policy: Some(AskForApproval::OnRequest), sandbox_policy: Some(SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, network_access: false, }), dynamic_tools, @@ -912,7 +909,6 @@ async fn trigger_patch_approval( experimental_api: true, approval_policy: Some(AskForApproval::OnRequest), sandbox_policy: Some(SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, network_access: false, }), dynamic_tools, @@ -1129,6 +1125,7 @@ async fn thread_list(endpoint: &Endpoint, config_overrides: &[String], limit: u3 source_kinds: None, archived: None, cwd: None, + use_state_db_only: false, search_term: None, })?; println!("< thread/list response: {response:?}"); diff --git a/codex-rs/app-server/BUILD.bazel b/codex-rs/app-server/BUILD.bazel index d2d3f42a19..b7ff5b1695 100644 --- a/codex-rs/app-server/BUILD.bazel +++ b/codex-rs/app-server/BUILD.bazel @@ -5,7 +5,13 @@ codex_rust_crate( crate_name = "codex_app_server", integration_test_timeout = "long", test_shard_counts = { - "app-server-all-test": 8, + # Note app-server-all-test has a large number of integration tests, so + # even a single shard can be quite slow. When there is a legitimate + # test failure in a shard, it will still get run 3x in total, which + # can cause us to exhaust our CI timeout if the shard happens to run + # long. Using a higher shard count for app-server-all-test should help + # mitigate this risk. + "app-server-all-test": 16, "app-server-unit-tests": 8, }, test_tags = ["no-sandbox"], diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index bec863eb6a..06ed624c37 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -30,6 +30,7 @@ axum = { workspace = true, default-features = false, features = [ "ws", ] } codex-analytics = { workspace = true } +codex-api = { workspace = true } codex-arg0 = { workspace = true } codex-cloud-requirements = { workspace = true } codex-config = { workspace = true } @@ -48,6 +49,7 @@ codex-file-search = { workspace = true } codex-chatgpt = { workspace = true } codex-login = { workspace = true } codex-mcp = { workspace = true } +codex-model-provider = { workspace = true } codex-models-manager = { workspace = true } codex-protocol = { workspace = true } codex-app-server-protocol = { workspace = true } @@ -58,6 +60,7 @@ codex-sandboxing = { workspace = true } codex-state = { workspace = true } codex-thread-store = { workspace = true } codex-tools = { workspace = true } +codex-uds = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-json-to-toml = { workspace = true } codex-utils-rustls-provider = { workspace = true } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 7e23cc24a7..639bfce5c9 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -25,6 +25,7 @@ Supported transports: - stdio (`--listen stdio://`, default): newline-delimited JSON (JSONL) - websocket (`--listen ws://IP:PORT`): one JSON-RPC message per websocket text frame (**experimental / unsupported**) +- unix socket (`--listen unix://` or `--listen unix://PATH`): websocket connections over `$CODEX_HOME/app-server-control/app-server-control.sock` or a custom socket path, using the standard HTTP Upgrade handshake - off (`--listen off`): do not expose a local transport When running with `--listen ws://IP:PORT`, the same listener also serves basic HTTP health probes: @@ -35,6 +36,11 @@ When running with `--listen ws://IP:PORT`, the same listener also serves basic H Websocket transport is currently experimental and unsupported. Do not rely on it for production workloads. +The unix socket transport is intended for local app-server control-plane clients. `codex app-server proxy` +opens exactly one raw stream connection to `$CODEX_HOME/app-server-control/app-server-control.sock` +by default, or to `--sock PATH` when provided, and proxies bytes between that socket and stdin/stdout. +The proxied stream carries the websocket HTTP Upgrade handshake followed by websocket frames. + Security note: - Loopback websocket listeners (`ws://127.0.0.1:PORT`) remain appropriate for localhost and SSH port-forwarding workflows. @@ -82,7 +88,7 @@ Use the thread APIs to create, list, or archive conversations. Drive a conversat - Initialize once per connection: Immediately after opening a transport connection, send an `initialize` request with your client metadata, then emit an `initialized` notification. Any other request on that connection before this handshake gets rejected. - Start (or resume) a thread: Call `thread/start` to open a fresh conversation. The response returns the thread object and you’ll also get a `thread/started` notification. If you’re continuing an existing conversation, call `thread/resume` with its ID instead. If you want to branch from an existing conversation, call `thread/fork` to create a new thread id with copied history. Like `thread/start`, `thread/fork` also accepts `ephemeral: true` for an in-memory temporary thread. The returned `thread.ephemeral` flag tells you whether the session is intentionally in-memory only; when it is `true`, `thread.path` is `null`. -- Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, sandbox policy, approval policy, approvals reviewer, etc. This immediately returns the new turn object. The app-server emits `turn/started` when that turn actually begins running. +- Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, sandbox policy or `permissionProfile`, approval policy, approvals reviewer, etc. This immediately returns the new turn object. The app-server emits `turn/started` when that turn actually begins running. - Stream events: After `turn/start`, keep reading JSON-RPC notifications on stdout. You’ll see `item/started`, `item/completed`, deltas like `item/agentMessage/delta`, tool progress, etc. These represent streaming model output plus any side effects (commands, tool calls, reasoning notes). - Finish the turn: When the model is done (or the turn is interrupted via making the `turn/interrupt` call), the server sends `turn/completed` with the final turn state and token usage. @@ -136,9 +142,9 @@ Example with notification opt-out: ## API Overview -- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. -- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. -- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. +- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. For permissions, prefer `permissionProfile`; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissionProfile`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. +- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. +- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Pass `excludeTurns: true` when the client plans to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. @@ -146,6 +152,11 @@ Example with notification opt-out: - `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`. - `thread/memoryMode/set` — experimental; set a thread’s persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success. - `memory/reset` — experimental; clear the current `CODEX_HOME/memories` directory and reset persisted memory stage data in sqlite while preserving existing thread memory modes; returns `{}` on success. +- `thread/goal/set` — create, replace, or update the single persisted goal for a materialized thread; returns the current goal and emits `thread/goal/updated`. Supplying a new `objective` replaces the goal and resets usage accounting. Supplying the current non-terminal objective or omitting `objective` updates the existing goal’s status and/or token budget while preserving usage. +- `thread/goal/get` — fetch the current persisted goal for a materialized thread; returns `goal: null` when no goal exists. +- `thread/goal/clear` — clear the current persisted goal for a materialized thread; returns whether a goal was removed and emits `thread/goal/cleared` when state changes. +- `thread/goal/updated` — notification emitted whenever a thread goal changes; includes the full current goal. +- `thread/goal/cleared` — notification emitted whenever a thread goal is removed. - `thread/status/changed` — notification emitted when a loaded thread’s status changes (`threadId` + new `status`). - `thread/archive` — move a thread’s rollout file into the archived directory and attempt to move any spawned descendant thread rollout files; returns `{}` on success and emits `thread/archived` for each archived thread. - `thread/unsubscribe` — unsubscribe this connection from thread turn/item events. If this was the last subscriber, the server keeps the thread loaded and unloads it only after it has had no subscribers and no thread activity for 30 minutes, then emits `thread/closed`. @@ -155,7 +166,7 @@ Example with notification opt-out: - `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream. - `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. -- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". +- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. Prefer `permissionProfile` for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissionProfile`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. @@ -186,6 +197,7 @@ Example with notification opt-out: - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). - `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present. - `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists. +- `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors. - `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**). - `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**). - `skills/changed` — notification emitted when watched local skill files change. @@ -209,7 +221,7 @@ Example with notification opt-out: - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin `details` returned by detect. When a request includes plugin imports, the server emits `externalAgentConfig/import/completed` after the full import finishes (immediately after the response when everything completed synchronously, or after background remote imports finish). - `config/value/write` — write a single config key/value to the user's config.toml on disk. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads. -- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly`. +- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. ### Example: Start or resume a thread @@ -223,6 +235,8 @@ Start a fresh thread when you need a new Codex conversation. "cwd": "/Users/me/project", "approvalPolicy": "never", "sandbox": "workspaceWrite", + // Prefer "permissionProfile" for full permission overrides. Do not send + // both "sandbox" and "permissionProfile". "personality": "friendly", "serviceName": "my_app_server_client", // optional metrics tag (`service_name`) "sessionStartSource": "startup", // optional: "startup" (default) or "clear" @@ -257,6 +271,8 @@ Valid `personality` values are `"friendly"`, `"pragmatic"`, and `"none"`. When ` To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`. When the stored session includes persisted token usage, the server emits `thread/tokenUsage/updated` immediately after the response so clients can render restored usage before the next turn starts. You can also pass the same configuration overrides supported by `thread/start`, including `approvalsReviewer`. +By default, `thread/resume` includes the reconstructed turn history in `thread.turns`. Pass `excludeTurns: true` to return only thread metadata and live resume state, then call `thread/turns/list` separately if you want to page the turn history over the network. In that mode the server also skips replaying restored `thread/tokenUsage/updated`, which avoids rebuilding turns just to attribute historical usage. + By default, resume uses the latest persisted `model` and `reasoningEffort` values associated with the thread. Supplying any of `model`, `modelProvider`, `config.model`, or `config.model_reasoning_effort` disables that persisted fallback and uses the explicit overrides plus normal config resolution instead. Example: @@ -267,6 +283,12 @@ Example: "personality": "friendly" } } { "id": 11, "result": { "thread": { "id": "thr_123", … } } } + +{ "method": "thread/resume", "id": 12, "params": { + "threadId": "thr_123", + "excludeTurns": true +} } +{ "id": 12, "result": { "thread": { "id": "thr_123", "turns": [], … } } } ``` To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. When the source history includes persisted token usage, the server also emits `thread/tokenUsage/updated` for the new thread immediately after the response. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only: @@ -277,6 +299,8 @@ To branch from a stored session, call `thread/fork` with the `thread.id`. This c { "method": "thread/started", "params": { "thread": { … } } } ``` +Like `thread/resume`, `thread/fork` also accepts `excludeTurns: true` to return only thread metadata in `thread.turns` and let the client page history with `thread/turns/list`. In that mode the server skips replaying restored `thread/tokenUsage/updated`, which keeps the fork path from rebuilding turns just to attribute historical usage. + Experimental API: `thread/start`, `thread/resume`, and `thread/fork` accept `persistExtendedHistory: true` to persist a richer subset of ThreadItems for non-lossy history when calling `thread/read`, `thread/resume`, and `thread/fork` later. This does not backfill events that were not persisted previously. ### Example: List threads (with pagination & filters) @@ -290,7 +314,8 @@ Experimental API: `thread/start`, `thread/resume`, and `thread/fork` accept `per - `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers. - `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`). - `archived` — when `true`, list archived threads only. When `false` or `null`, list non-archived threads (default). -- `cwd` — restrict results to threads whose session cwd exactly matches this path. Relative paths are resolved against the app-server process cwd before matching. +- `cwd` — restrict results to threads whose session cwd exactly matches this path, or one of these paths when an array is provided. Relative paths are resolved against the app-server process cwd before matching. +- `useStateDbOnly` — when `true`, return from the state DB without scanning JSONL rollouts to repair metadata. Omit or pass `false` to preserve the default scan-and-repair behavior. - `searchTerm` — restrict results to threads whose extracted title contains this substring (case-sensitive). - Responses include `nextCursor` to continue in the same direction and `backwardsCursor` to pass as `cursor` when reversing `sortDirection`. - Responses include `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available. @@ -301,6 +326,7 @@ Example: { "method": "thread/list", "id": 20, "params": { "cursor": null, "limit": 25, + "cwd": ["/Users/me/project", "/Users/me/project-worktree"], "sortKey": "created_at" } } { "id": 20, "result": { @@ -449,6 +475,70 @@ Experimental: use `memory/reset` to clear local memory artifacts and sqlite-back { "id": 27, "result": {} } ``` +### Example: Set and update a thread goal + +Use `thread/goal/set` with an `objective` to create or replace the current goal for a materialized thread. Supplying a new objective resets `tokensUsed`, `timeUsedSeconds`, and `createdAt`. Supplying the current non-terminal objective, or omitting `objective`, updates the existing goal’s status or token budget while preserving usage history. Clients can set `budgetLimited` when they stop because a token budget is exhausted or nearly exhausted; the system also sets it when accounting crosses a configured token budget. + +```json +{ "method": "thread/goal/set", "id": 27, "params": { + "threadId": "thr_123", + "objective": "Keep improving the benchmark until p95 latency is under 120ms", + "tokenBudget": 200000 +} } +{ "id": 27, "result": { "goal": { + "threadId": "thr_123", + "objective": "Keep improving the benchmark until p95 latency is under 120ms", + "status": "active", + "tokenBudget": 200000, + "tokensUsed": 0, + "timeUsedSeconds": 0, + "createdAt": 1776272400, + "updatedAt": 1776272400 +} } } +{ "method": "thread/goal/updated", "params": { "threadId": "thr_123", "goal": { + "threadId": "thr_123", + "objective": "Keep improving the benchmark until p95 latency is under 120ms", + "status": "active", + "tokenBudget": 200000, + "tokensUsed": 0, + "timeUsedSeconds": 0, + "createdAt": 1776272400, + "updatedAt": 1776272400 +} } } +``` + +```json +{ "method": "thread/goal/set", "id": 28, "params": { + "threadId": "thr_123", + "status": "paused" +} } +{ "id": 28, "result": { "goal": { + "threadId": "thr_123", + "objective": "Keep improving the benchmark until p95 latency is under 120ms", + "status": "paused", + "tokenBudget": 200000, + "tokensUsed": 10000, + "timeUsedSeconds": 60, + "createdAt": 1776272400, + "updatedAt": 1776272460 +} } } +``` + +Use `thread/goal/get` to read the current goal without changing it. + +```json +{ "method": "thread/goal/get", "id": 29, "params": { "threadId": "thr_123" } } +{ "id": 29, "result": { "goal": null } } +``` + +Use `thread/goal/clear` to remove the current goal. + +```json +{ "method": "thread/goal/clear", "id": 30, "params": { "threadId": "thr_123" } } +{ "id": 30, "result": { "cleared": true } } +{ "method": "thread/goal/cleared", "params": { "threadId": "thr_123" } } +``` + ### Example: Archive a thread Use `thread/archive` to move the persisted rollout (stored as a JSONL file on disk) into the archived sessions directory and attempt to move any spawned descendant thread rollouts. @@ -520,12 +610,12 @@ Turns attach user input (text or images) to a thread and trigger Codex generatio - `{"type":"image","url":"https://…png"}` - `{"type":"localImage","path":"/tmp/screenshot.png"}` -You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread. `outputSchema` applies only to the current turn. +You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread. `outputSchema` applies only to the current turn. Experimental `environments` is turn-scoped: omit it to inherit the thread's sticky environments, pass `[]` to run the turn with no environments, or pass explicit environment ids to override the sticky selection for this turn only. `approvalsReviewer` accepts: - `"user"` — default. Review approval requests directly in the client. -- `"guardian_subagent"` — route approval requests to a carefully prompted subagent, which gathers relevant context and applies a risk-based decision framework before approving or denying the request. +- `"auto_review"` — route approval requests to a carefully prompted subagent, which gathers relevant context and applies a risk-based decision framework before approving or denying the request. The legacy value `"guardian_subagent"` is still accepted for compatibility. ```json { "method": "turn/start", "id": 30, "params": { @@ -533,12 +623,18 @@ You can optionally specify config overrides on the new turn. If specified, these "input": [ { "type": "text", "text": "Run tests" } ], // Below are optional config overrides "cwd": "/Users/me/project", + // Experimental: turn-scoped environment selection. + "environments": [ + { "environmentId": "local", "cwd": "/Users/me/project" } + ], "approvalPolicy": "unlessTrusted", "sandboxPolicy": { "type": "workspaceWrite", "writableRoots": ["/Users/me/project"], "networkAccess": true }, + // Prefer "permissionProfile" for full permission overrides. Do not send + // both "sandboxPolicy" and "permissionProfile". "model": "gpt-5.1-codex", "effort": "medium", "summary": "concise", @@ -809,7 +905,14 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin "cwd": "/Users/me/project", // optional; defaults to server cwd "env": { "FOO": "override" }, // optional; merges into the server env and overrides matching names "size": { "rows": 40, "cols": 120 }, // optional; PTY size in character cells, only valid with tty=true - "sandboxPolicy": { "type": "workspaceWrite" }, // optional; defaults to user config + "permissionProfile": { // optional; defaults to user config + "type": "managed", + "fileSystem": { "type": "restricted", "entries": [ + { "path": { "type": "special", "value": { "kind": "root" } }, "access": "read" }, + { "path": { "type": "special", "value": { "kind": "project_roots", "subpath": null } }, "access": "write" } + ] }, + "network": { "enabled": false } + }, "outputBytesCap": 1048576, // optional; per-stream capture cap "disableOutputCap": false, // optional; cannot be combined with outputBytesCap "timeoutMs": 10000, // optional; ms timeout; defaults to server timeout @@ -822,12 +925,12 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin } } ``` -- For clients that are already sandboxed externally, set `sandboxPolicy` to `{"type":"externalSandbox","networkAccess":"enabled"}` (or omit `networkAccess` to keep it restricted). Codex will not enforce its own sandbox in this mode; it tells the model it has full file-system access and passes the `networkAccess` state through `environment_context`. +- For clients that are already sandboxed externally, set the legacy `sandboxPolicy` to `{"type":"externalSandbox","networkAccess":"enabled"}` (or omit `networkAccess` to keep it restricted). Codex will not enforce its own sandbox in this mode; it tells the model it has full file-system access and passes the `networkAccess` state through `environment_context`. Notes: - Empty `command` arrays are rejected. -- `sandboxPolicy` accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags, `externalSandbox` with `networkAccess` `restricted|enabled`). +- Prefer `permissionProfile` for command permission overrides. The legacy `sandboxPolicy` field accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags, `externalSandbox` with `networkAccess` `restricted|enabled`), but cannot be combined with `permissionProfile`. - `env` merges into the environment produced by the server's shell environment policy. Matching names are overridden; unspecified variables are left intact. - When omitted, `timeoutMs` falls back to the server default. - When omitted, `outputBytesCap` falls back to the server default of 1 MiB per stream. @@ -1018,6 +1121,7 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn - `turn/diff/updated` — `{ threadId, turnId, diff }` represents the up-to-date snapshot of the turn-level unified diff, emitted after every FileChange item. `diff` is the latest aggregated unified diff across every file change in the turn. UIs can render this to show the full "what changed" view without stitching individual `fileChange` items. - `turn/plan/updated` — `{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`. - `model/rerouted` — `{ threadId, turnId, fromModel, toModel, reason }` when the backend reroutes a request to a different model (for example, due to high-risk cyber safety checks). +- `model/verification` — `{ threadId, turnId, verifications }` when the backend flags additional account verification, such as `trustedAccessForCyber`. Today both notifications carry an empty `items` array even when item events were streamed; rely on `item/*` notifications for the canonical item list until this is fixed. @@ -1151,7 +1255,7 @@ the client can offer session-scoped and/or persistent approval choices. ### Permission requests -The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the command-execution `additionalPermissions` shape: it can request network access and additional filesystem access. The `cwd` field identifies the directory used to resolve cwd-relative permissions such as `:cwd`, `:project_roots`, and relative deny globs. +The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the command-execution `additionalPermissions` shape: it can request network access and additional filesystem access. The `cwd` field identifies the directory used to resolve project-root permissions and relative deny globs. ```json { @@ -1198,7 +1302,7 @@ If the session approval policy uses `Granular` with `request_permissions: false` `dynamicTools` on `thread/start` and the corresponding `item/tool/call` request/response flow are experimental APIs. To enable them, set `initialize.params.capabilities.experimentalApi = true`. -Each dynamic tool may set `deferLoading`. When omitted, it defaults to `false`. Set it to `true` to keep the tool registered and callable by runtime features such as `js_repl`, while excluding it from the model-facing tool list sent on ordinary turns. When `tool_search` is available, deferred dynamic tools are searchable and can be exposed by a matching search result. +Each dynamic tool may set `deferLoading`. When omitted, it defaults to `false`. Set it to `true` to keep the tool registered and callable by runtime features such as `code_mode`, while excluding it from the model-facing tool list sent on ordinary turns. When `tool_search` is available, deferred dynamic tools are searchable and can be exposed by a matching search result. When a dynamic tool is invoked during a turn, the server sends an `item/tool/call` JSON-RPC request to the client: diff --git a/codex-rs/app-server/src/app_server_tracing.rs b/codex-rs/app-server/src/app_server_tracing.rs index 2118e77300..6e8133740f 100644 --- a/codex-rs/app-server/src/app_server_tracing.rs +++ b/codex-rs/app-server/src/app_server_tracing.rs @@ -23,7 +23,7 @@ use tracing::info_span; pub(crate) fn request_span( request: &JSONRPCRequest, - transport: AppServerTransport, + transport: &AppServerTransport, connection_id: ConnectionId, session: &ConnectionSessionState, ) -> Span { @@ -82,9 +82,10 @@ pub(crate) fn typed_request_span( span } -fn transport_name(transport: AppServerTransport) -> &'static str { +fn transport_name(transport: &AppServerTransport) -> &'static str { match transport { AppServerTransport::Stdio => "stdio", + AppServerTransport::UnixSocket { .. } => "unix_socket", AppServerTransport::WebSocket { .. } => "websocket", AppServerTransport::Off => "off", } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 56cd688d2a..04de06f469 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2,8 +2,8 @@ use crate::codex_message_processor::ApiVersion; use crate::codex_message_processor::read_rollout_items_from_rollout; use crate::codex_message_processor::read_summary_from_rollout; use crate::codex_message_processor::summary_to_thread; -use crate::error_code::INTERNAL_ERROR_CODE; -use crate::error_code::INVALID_REQUEST_ERROR_CODE; +use crate::error_code::internal_error; +use crate::error_code::invalid_request; use crate::outgoing_message::ClientRequestResult; use crate::outgoing_message::ThreadScopedOutgoingMessageSender; use crate::server_request_error::is_turn_transition_server_request_error; @@ -45,12 +45,12 @@ use codex_app_server_protocol::FileChangeRequestApprovalParams; use codex_app_server_protocol::FileChangeRequestApprovalResponse; use codex_app_server_protocol::FileUpdateChange; use codex_app_server_protocol::GrantedPermissionProfile as V2GrantedPermissionProfile; +use codex_app_server_protocol::GuardianWarningNotification; use codex_app_server_protocol::HookCompletedNotification; use codex_app_server_protocol::HookStartedNotification; use codex_app_server_protocol::InterruptConversationResponse; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; -use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::McpServerElicitationAction; use codex_app_server_protocol::McpServerElicitationRequestParams; use codex_app_server_protocol::McpServerElicitationRequestResponse; @@ -60,6 +60,7 @@ use codex_app_server_protocol::McpToolCallError; use codex_app_server_protocol::McpToolCallResult; use codex_app_server_protocol::McpToolCallStatus; use codex_app_server_protocol::ModelReroutedNotification; +use codex_app_server_protocol::ModelVerificationNotification; use codex_app_server_protocol::NetworkApprovalContext as V2NetworkApprovalContext; use codex_app_server_protocol::NetworkPolicyAmendment as V2NetworkPolicyAmendment; use codex_app_server_protocol::NetworkPolicyRuleAction as V2NetworkPolicyRuleAction; @@ -76,6 +77,7 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::SkillsChangedNotification; use codex_app_server_protocol::TerminalInteractionNotification; +use codex_app_server_protocol::ThreadGoalUpdatedNotification; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadNameUpdatedNotification; use codex_app_server_protocol::ThreadRealtimeClosedNotification; @@ -120,7 +122,7 @@ use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem; use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse; use codex_protocol::items::parse_hook_prompt_message; -use codex_protocol::models::PermissionProfile as CorePermissionProfile; +use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; use codex_protocol::protocol::Event; @@ -220,6 +222,7 @@ pub(crate) async fn apply_bespoke_event_handling( EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; + respond_to_pending_interrupts(&thread_state, &outgoing, /*abort_reason*/ None).await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) @@ -286,6 +289,22 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } + EventMsg::GuardianWarning(warning_event) => { + if let ApiVersion::V2 = api_version { + let notification = GuardianWarningNotification { + thread_id: conversation_id.to_string(), + message: warning_event.message, + }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client.track_notification( + ServerNotification::GuardianWarning(notification.clone()), + ); + } + outgoing + .send_server_notification(ServerNotification::GuardianWarning(notification)) + .await; + } + } EventMsg::GuardianAssessment(assessment) => { if let ApiVersion::V2 = api_version { let pending_command_execution = match build_item_from_guardian_event( @@ -382,6 +401,18 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } + EventMsg::ModelVerification(event) => { + if let ApiVersion::V2 = api_version { + let notification = ModelVerificationNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + verifications: event.verifications.into_iter().map(Into::into).collect(), + }; + outgoing + .send_server_notification(ServerNotification::ModelVerification(notification)) + .await; + } + } EventMsg::RealtimeConversationStarted(event) => { if let ApiVersion::V2 = api_version { let notification = ThreadRealtimeStartedNotification { @@ -944,6 +975,7 @@ pub(crate) async fn apply_bespoke_event_handling( let empty = CoreRequestPermissionsResponse { permissions: Default::default(), scope: CorePermissionGrantScope::Turn, + strict_auto_review: false, }; if let Err(err) = conversation .submit(Op::RequestPermissionsResponse { @@ -1815,26 +1847,12 @@ pub(crate) async fn apply_bespoke_event_handling( EventMsg::TurnAborted(turn_aborted_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; - let pending = { - let mut state = thread_state.lock().await; - std::mem::take(&mut state.pending_interrupts) - }; - if !pending.is_empty() { - for (rid, ver) in pending { - match ver { - ApiVersion::V1 => { - let response = InterruptConversationResponse { - abort_reason: turn_aborted_event.reason.clone(), - }; - outgoing.send_response(rid, response).await; - } - ApiVersion::V2 => { - let response = TurnInterruptResponse {}; - outgoing.send_response(rid, response).await; - } - } - } - } + respond_to_pending_interrupts( + &thread_state, + &outgoing, + Some(turn_aborted_event.reason.clone()), + ) + .await; thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) @@ -1857,12 +1875,12 @@ pub(crate) async fn apply_bespoke_event_handling( if let Some(request_id) = pending { let Some(rollout_path) = conversation.rollout_path() else { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "thread has no persisted rollout".to_string(), - data: None, - }; - outgoing.send_error(request_id, error).await; + outgoing + .send_error( + request_id, + invalid_request("thread has no persisted rollout"), + ) + .await; return; }; let response = match read_summary_from_rollout( @@ -1893,29 +1911,29 @@ pub(crate) async fn apply_bespoke_event_handling( ThreadRollbackResponse { thread } } Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!( - "failed to load rollout `{}`: {err}", - rollout_path.display() - ), - data: None, - }; - outgoing.send_error(request_id.clone(), error).await; + outgoing + .send_error( + request_id.clone(), + internal_error(format!( + "failed to load rollout `{}`: {err}", + rollout_path.display() + )), + ) + .await; return; } } } Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!( - "failed to load rollout `{}`: {err}", - rollout_path.display() - ), - data: None, - }; - outgoing.send_error(request_id.clone(), error).await; + outgoing + .send_error( + request_id.clone(), + internal_error(format!( + "failed to load rollout `{}`: {err}", + rollout_path.display() + )), + ) + .await; return; } }; @@ -1936,6 +1954,20 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } + EventMsg::ThreadGoalUpdated(thread_goal_event) => { + if let ApiVersion::V2 = api_version { + let notification = ThreadGoalUpdatedNotification { + thread_id: thread_goal_event.thread_id.to_string(), + turn_id: thread_goal_event.turn_id, + goal: thread_goal_event.goal.clone().into(), + }; + outgoing + .send_global_server_notification(ServerNotification::ThreadGoalUpdated( + notification, + )) + .await; + } + } EventMsg::TurnDiff(turn_diff_event) => { handle_turn_diff( conversation_id, @@ -2299,18 +2331,38 @@ async fn handle_thread_rollback_failed( if let Some(request_id) = pending_rollback { outgoing - .send_error( - request_id, - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: message.clone(), - data: None, - }, - ) + .send_error(request_id, invalid_request(message)) .await; } } +async fn respond_to_pending_interrupts( + thread_state: &Arc>, + outgoing: &ThreadScopedOutgoingMessageSender, + abort_reason: Option, +) { + let pending = { + let mut state = thread_state.lock().await; + std::mem::take(&mut state.pending_interrupts) + }; + + for (rid, ver) in pending { + match ver { + ApiVersion::V1 => { + let Some(abort_reason) = abort_reason.clone() else { + debug_assert!(false, "v1 interrupts only resolve from TurnAborted"); + continue; + }; + let response = InterruptConversationResponse { abort_reason }; + outgoing.send_response(rid, response).await; + } + ApiVersion::V2 => { + outgoing.send_response(rid, TurnInterruptResponse {}).await; + } + } + } +} + async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, @@ -2652,6 +2704,7 @@ fn request_permissions_response_from_client_result( return Some(CoreRequestPermissionsResponse { permissions: Default::default(), scope: CorePermissionGrantScope::Turn, + strict_auto_review: false, }); } Err(err) => { @@ -2659,6 +2712,7 @@ fn request_permissions_response_from_client_result( return Some(CoreRequestPermissionsResponse { permissions: Default::default(), scope: CorePermissionGrantScope::Turn, + strict_auto_review: false, }); } }; @@ -2669,9 +2723,24 @@ fn request_permissions_response_from_client_result( PermissionsRequestApprovalResponse { permissions: V2GrantedPermissionProfile::default(), scope: codex_app_server_protocol::PermissionGrantScope::Turn, + strict_auto_review: None, } }); - let granted_permissions: CorePermissionProfile = response.permissions.into(); + let strict_auto_review = response.strict_auto_review.unwrap_or(false); + if strict_auto_review + && matches!( + response.scope, + codex_app_server_protocol::PermissionGrantScope::Session + ) + { + error!("strict auto review is only supported for turn-scoped permission grants"); + return Some(CoreRequestPermissionsResponse { + permissions: Default::default(), + scope: CorePermissionGrantScope::Turn, + strict_auto_review: false, + }); + } + let granted_permissions: CoreAdditionalPermissionProfile = response.permissions.into(); let permissions = if granted_permissions.is_empty() { CoreRequestPermissionProfile::default() } else { @@ -2680,6 +2749,7 @@ fn request_permissions_response_from_client_result( Some(CoreRequestPermissionsResponse { permissions, scope: response.scope.to_core(), + strict_auto_review, }) } @@ -3107,6 +3177,7 @@ mod tests { last_agent_message: None, completed_at: Some(TEST_TURN_COMPLETED_AT), duration_ms: Some(TEST_TURN_DURATION_MS), + time_to_first_token_ms: None, } } @@ -3840,6 +3911,7 @@ mod tests { CoreRequestPermissionsResponse { permissions: expected_permissions, scope: CorePermissionGrantScope::Turn, + strict_auto_review: false, } ); } @@ -3862,10 +3934,63 @@ mod tests { CoreRequestPermissionsResponse { permissions: CoreRequestPermissionProfile::default(), scope: CorePermissionGrantScope::Session, + strict_auto_review: false, } ); } + #[test] + fn request_permissions_response_rejects_session_scoped_strict_auto_review() { + let response = request_permissions_response_from_client_result( + CoreRequestPermissionProfile::default(), + Ok(Ok(serde_json::json!({ + "scope": "session", + "strictAutoReview": true, + "permissions": { + "network": { + "enabled": true, + }, + }, + }))), + std::env::current_dir().expect("current dir").as_path(), + ) + .expect("response should be accepted"); + + assert_eq!( + response, + CoreRequestPermissionsResponse { + permissions: CoreRequestPermissionProfile::default(), + scope: CorePermissionGrantScope::Turn, + strict_auto_review: false, + } + ); + } + + #[test] + fn request_permissions_response_preserves_turn_scoped_strict_auto_review() { + let response = request_permissions_response_from_client_result( + CoreRequestPermissionProfile { + network: Some(codex_protocol::models::NetworkPermissions { + enabled: Some(true), + }), + ..Default::default() + }, + Ok(Ok(serde_json::json!({ + "strictAutoReview": true, + "permissions": { + "network": { + "enabled": true, + }, + }, + }))), + std::env::current_dir().expect("current dir").as_path(), + ) + .expect("response should be accepted"); + + assert_eq!(response.scope, CorePermissionGrantScope::Turn); + assert!(response.strict_auto_review); + } + #[test] fn request_permissions_response_accepts_explicit_child_grant_for_requested_cwd_scope() { let temp_dir = TempDir::new().expect("temp dir"); @@ -3875,7 +4000,7 @@ mod tests { file_system: Some(CoreFileSystemPermissions { entries: vec![FileSystemSandboxEntry { path: FileSystemPath::Special { - value: FileSystemSpecialPath::CurrentWorkingDirectory, + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), }, access: FileSystemAccessMode::Write, }], @@ -3921,7 +4046,7 @@ mod tests { file_system: Some(CoreFileSystemPermissions { entries: vec![FileSystemSandboxEntry { path: FileSystemPath::Special { - value: FileSystemSpecialPath::CurrentWorkingDirectory, + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), }, access: FileSystemAccessMode::Write, }], @@ -3971,7 +4096,8 @@ mod tests { "path": { "type": "special", "value": { - "kind": "current_working_directory" + "kind": "project_roots", + "subpath": null } }, "access": "write" @@ -4088,17 +4214,19 @@ mod tests { let thread_state = new_thread_state(); { let mut state = thread_state.lock().await; - state.track_current_turn_event(&EventMsg::TurnStarted( - codex_protocol::protocol::TurnStartedEvent { + state.track_current_turn_event( + &event_turn_id, + &EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { turn_id: event_turn_id.clone(), started_at: Some(42), model_context_window: None, collaboration_mode_kind: Default::default(), - }, - )); - state.track_current_turn_event(&EventMsg::TurnComplete(turn_complete_event( + }), + ); + state.track_current_turn_event( &event_turn_id, - ))); + &EventMsg::TurnComplete(turn_complete_event(&event_turn_id)), + ); } handle_turn_complete( diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 8f31288cd5..6a1e6fbae8 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -7,6 +7,7 @@ use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE; use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_PARAMS_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; +use crate::error_code::invalid_params; use crate::fuzzy_file_search::FuzzyFileSearchSession; use crate::fuzzy_file_search::run_fuzzy_file_search; use crate::fuzzy_file_search::start_fuzzy_file_search_session; @@ -32,11 +33,11 @@ use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::AddCreditsNudgeCreditType; use codex_app_server_protocol::AddCreditsNudgeEmailStatus; use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::AppSummary; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::AuthMode; -use codex_app_server_protocol::AuthMode as CoreAuthMode; use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; @@ -87,6 +88,9 @@ use codex_app_server_protocol::MarketplaceAddResponse; use codex_app_server_protocol::MarketplaceInterface; use codex_app_server_protocol::MarketplaceRemoveParams; use codex_app_server_protocol::MarketplaceRemoveResponse; +use codex_app_server_protocol::MarketplaceUpgradeErrorInfo; +use codex_app_server_protocol::MarketplaceUpgradeParams; +use codex_app_server_protocol::MarketplaceUpgradeResponse; use codex_app_server_protocol::McpResourceReadParams; use codex_app_server_protocol::McpResourceReadResponse; use codex_app_server_protocol::McpServerOauthLoginCompletedNotification; @@ -102,6 +106,7 @@ use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::MockExperimentalMethodResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; +use codex_app_server_protocol::PermissionProfile as ApiPermissionProfile; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; @@ -132,6 +137,8 @@ use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::SortDirection; use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadApproveGuardianDeniedActionParams; +use codex_app_server_protocol::ThreadApproveGuardianDeniedActionResponse; use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadArchiveResponse; use codex_app_server_protocol::ThreadArchivedNotification; @@ -144,11 +151,22 @@ use codex_app_server_protocol::ThreadDecrementElicitationParams; use codex_app_server_protocol::ThreadDecrementElicitationResponse; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; +use codex_app_server_protocol::ThreadGoal; +use codex_app_server_protocol::ThreadGoalClearParams; +use codex_app_server_protocol::ThreadGoalClearResponse; +use codex_app_server_protocol::ThreadGoalClearedNotification; +use codex_app_server_protocol::ThreadGoalGetParams; +use codex_app_server_protocol::ThreadGoalGetResponse; +use codex_app_server_protocol::ThreadGoalSetParams; +use codex_app_server_protocol::ThreadGoalSetResponse; +use codex_app_server_protocol::ThreadGoalStatus; +use codex_app_server_protocol::ThreadGoalUpdatedNotification; use codex_app_server_protocol::ThreadIncrementElicitationParams; use codex_app_server_protocol::ThreadIncrementElicitationResponse; use codex_app_server_protocol::ThreadInjectItemsParams; use codex_app_server_protocol::ThreadInjectItemsResponse; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadListCwdFilter; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadLoadedListParams; @@ -212,12 +230,18 @@ use codex_arg0::Arg0DispatchPaths; use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType; use codex_backend_client::Client as BackendClient; use codex_chatgpt::connectors; +use codex_chatgpt::workspace_settings; +use codex_config::CloudRequirementsLoadError; +use codex_config::CloudRequirementsLoadErrorCode; +use codex_config::loader::project_trust_key; use codex_config::types::McpServerTransportConfig; use codex_core::CodexThread; +use codex_core::CodexThreadTurnContextOverrides; use codex_core::ForkSnapshot; use codex_core::NewThread; use codex_core::RolloutRecorder; use codex_core::SessionMeta; +use codex_core::StartThreadWithToolsOptions; use codex_core::SteerInputError; use codex_core::ThreadConfigSnapshot; use codex_core::ThreadManager; @@ -225,11 +249,9 @@ use codex_core::clear_memory_roots_contents; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::NetworkProxyAuditMetadata; +use codex_core::config::ThreadStoreConfig; use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; -use codex_core::config_loader::CloudRequirementsLoadError; -use codex_core::config_loader::CloudRequirementsLoadErrorCode; -use codex_core::config_loader::project_trust_key; use codex_core::exec::ExecCapturePolicy; use codex_core::exec::ExecExpiration; use codex_core::exec::ExecParams; @@ -239,27 +261,33 @@ use codex_core::find_thread_name_by_id; use codex_core::find_thread_names_by_ids; use codex_core::find_thread_path_by_id_str; use codex_core::path_utils; -use codex_core::plugins::MarketplaceAddError; -use codex_core::plugins::MarketplaceRemoveError; -use codex_core::plugins::MarketplaceRemoveRequest as CoreMarketplaceRemoveRequest; -use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core::plugins::PluginInstallError as CorePluginInstallError; use codex_core::plugins::PluginInstallRequest; use codex_core::plugins::PluginReadRequest; use codex_core::plugins::PluginUninstallError as CorePluginUninstallError; -use codex_core::plugins::add_marketplace as add_marketplace_to_codex_home; -use codex_core::plugins::remove_marketplace; use codex_core::read_head_for_summary; use codex_core::read_session_meta_line; use codex_core::sandboxing::SandboxPermissions; use codex_core::windows_sandbox::WindowsSandboxLevelExt; use codex_core::windows_sandbox::WindowsSandboxSetupMode as CoreWindowsSandboxSetupMode; use codex_core::windows_sandbox::WindowsSandboxSetupRequest; +use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core_plugins::loader::load_plugin_apps; use codex_core_plugins::loader::load_plugin_mcp_servers; use codex_core_plugins::manifest::PluginManifestInterface; use codex_core_plugins::marketplace::MarketplaceError; use codex_core_plugins::marketplace::MarketplacePluginSource; +use codex_core_plugins::marketplace_add::MarketplaceAddError; +use codex_core_plugins::marketplace_add::MarketplaceAddRequest; +use codex_core_plugins::marketplace_add::add_marketplace as add_marketplace_to_codex_home; +use codex_core_plugins::marketplace_remove::MarketplaceRemoveError; +use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest as CoreMarketplaceRemoveRequest; +use codex_core_plugins::marketplace_remove::remove_marketplace; +use codex_core_plugins::remote::RemoteMarketplace; +use codex_core_plugins::remote::RemotePluginCatalogError; +use codex_core_plugins::remote::RemotePluginDetail as RemoteCatalogPluginDetail; +use codex_core_plugins::remote::RemotePluginServiceConfig; +use codex_core_plugins::remote::RemotePluginSummary as RemoteCatalogPluginSummary; use codex_exec_server::EnvironmentManager; use codex_exec_server::LOCAL_FS; use codex_features::FEATURES; @@ -287,7 +315,10 @@ use codex_mcp::discover_supported_scopes; use codex_mcp::effective_mcp_servers; use codex_mcp::read_mcp_resource as read_mcp_resource_without_thread; use codex_mcp::resolve_oauth_scopes; +use codex_model_provider::ProviderAccountError; +use codex_model_provider::create_model_provider; use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig; +use codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ForcedLoginMethod; @@ -299,6 +330,7 @@ use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; +use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::ConversationStartParams; @@ -312,12 +344,14 @@ use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RealtimeVoicesList; +use codex_protocol::protocol::ResumedHistory; use codex_protocol::protocol::ReviewDelivery as CoreReviewDelivery; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionMetaLine; +use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::USER_MESSAGE_BEGIN; use codex_protocol::protocol::W3cTraceContext; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; @@ -331,8 +365,11 @@ use codex_state::ThreadMetadata; use codex_state::ThreadMetadataBuilder; use codex_state::log_db::LogDbLayer; use codex_thread_store::ArchiveThreadParams as StoreArchiveThreadParams; +#[cfg(debug_assertions)] +use codex_thread_store::InMemoryThreadStore; use codex_thread_store::ListThreadsParams as StoreListThreadsParams; use codex_thread_store::LocalThreadStore; +use codex_thread_store::ReadThreadByRolloutPathParams as StoreReadThreadByRolloutPathParams; use codex_thread_store::ReadThreadParams as StoreReadThreadParams; use codex_thread_store::RemoteThreadStore; use codex_thread_store::SortDirection as StoreSortDirection; @@ -373,6 +410,7 @@ use codex_app_server_protocol::ServerRequest; mod apps_list_helpers; mod plugin_app_helpers; mod plugin_mcp_oauth; +mod plugins; mod token_usage_replay; use crate::filters::compute_source_filters; @@ -382,7 +420,6 @@ use crate::thread_state::ThreadState; use crate::thread_state::ThreadStateManager; use token_usage_replay::latest_token_usage_turn_id_for_thread_path; use token_usage_replay::latest_token_usage_turn_id_from_rollout_items; -use token_usage_replay::latest_token_usage_turn_id_from_rollout_path; use token_usage_replay::send_thread_token_usage_update_to_connection; const THREAD_LIST_DEFAULT_LIMIT: usize = 25; @@ -394,8 +431,9 @@ struct ThreadListFilters { model_providers: Option>, source_kinds: Option>, archived: bool, - cwd: Option, + cwd_filters: Option>, search_term: Option, + use_state_db_only: bool, } // Duration before a browser ChatGPT login attempt is abandoned. @@ -455,6 +493,16 @@ enum ThreadReadViewError { Internal(String), } +mod thread_goal_handlers; +use self::thread_goal_handlers::api_thread_goal_from_state; + +fn thread_read_view_error(err: ThreadReadViewError) -> JSONRPCErrorError { + match err { + ThreadReadViewError::InvalidRequest(message) => invalid_request(message), + ThreadReadViewError::Internal(message) => internal_error(message), + } +} + impl Drop for ActiveLogin { fn drop(&mut self) { self.cancel(); @@ -476,6 +524,7 @@ pub(crate) struct CodexMessageProcessor { thread_state_manager: ThreadStateManager, thread_watch_manager: ThreadWatchManager, command_exec_manager: CommandExecManager, + workspace_settings_cache: Arc, pending_fuzzy_searches: Arc>>>, fuzzy_search_sessions: Arc>>, background_tasks: TaskTracker, @@ -498,7 +547,6 @@ struct ListenerTaskContext { outgoing: Arc, pending_thread_unloads: Arc>>, analytics_events_client: AnalyticsEventsClient, - general_analytics_enabled: bool, thread_watch_manager: ThreadWatchManager, fallback_model_provider: String, codex_home: PathBuf, @@ -636,14 +684,25 @@ pub(crate) struct CodexMessageProcessorArgs { } fn configured_thread_store(config: &Config) -> Arc { - match config.experimental_thread_store_endpoint.as_deref() { - Some(endpoint) => Arc::new(RemoteThreadStore::new(endpoint)), - None => Arc::new(LocalThreadStore::new( - codex_rollout::RolloutConfig::from_view(config), - )), + match &config.experimental_thread_store { + ThreadStoreConfig::Local => Arc::new(configured_local_thread_store(config)), + ThreadStoreConfig::Remote { endpoint } => Arc::new(RemoteThreadStore::new(endpoint)), + #[cfg(debug_assertions)] + ThreadStoreConfig::InMemory { id } => InMemoryThreadStore::for_id(id), } } +fn environment_selection_error_message(err: CodexErr) -> String { + match err { + CodexErr::InvalidRequest(message) => message, + err => err.to_string(), + } +} + +fn configured_local_thread_store(config: &Config) -> LocalThreadStore { + LocalThreadStore::new(codex_rollout::RolloutConfig::from_view(config)) +} + impl CodexMessageProcessor { async fn instruction_sources_from_config(config: &Config) -> Vec { codex_core::AgentsMdManager::new(config) @@ -674,14 +733,12 @@ impl CodexMessageProcessor { error: &JSONRPCErrorError, error_type: Option, ) { - if self.config.features.enabled(Feature::GeneralAnalytics) { - self.analytics_events_client.track_error_response( - request_id.connection_id.0, - request_id.request_id.clone(), - error.clone(), - error_type, - ); - } + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); } async fn load_thread( @@ -733,6 +790,9 @@ impl CodexMessageProcessor { thread_state_manager: ThreadStateManager::new(), thread_watch_manager: ThreadWatchManager::new_with_outgoing(outgoing), command_exec_manager: CommandExecManager::default(), + workspace_settings_cache: Arc::new( + workspace_settings::WorkspaceSettingsCache::default(), + ), pending_fuzzy_searches: Arc::new(Mutex::new(HashMap::new())), fuzzy_search_sessions: Arc::new(Mutex::new(HashMap::new())), background_tasks: TaskTracker::new(), @@ -755,6 +815,28 @@ impl CodexMessageProcessor { }) } + async fn workspace_codex_plugins_enabled( + &self, + config: &Config, + auth: Option<&CodexAuth>, + ) -> bool { + match workspace_settings::codex_plugins_enabled_for_workspace( + config, + auth, + Some(&self.workspace_settings_cache), + ) + .await + { + Ok(enabled) => enabled, + Err(err) => { + warn!( + "failed to fetch workspace Codex plugins setting; allowing Codex plugins: {err:#}" + ); + true + } + } + } + /// If a client sends `developer_instructions: null` during a mode switch, /// use the built-in instructions for that mode. fn normalize_turn_start_collaboration_mode( @@ -763,14 +845,12 @@ impl CodexMessageProcessor { collaboration_modes_config: CollaborationModesConfig, ) -> CollaborationMode { if collaboration_mode.settings.developer_instructions.is_none() - && let Some(instructions) = self - .thread_manager - .get_models_manager() - .list_collaboration_modes_for_config(collaboration_modes_config) - .into_iter() - .find(|preset| preset.mode == Some(collaboration_mode.mode)) - .and_then(|preset| preset.developer_instructions.flatten()) - .filter(|instructions| !instructions.is_empty()) + && let Some(instructions) = + builtin_collaboration_mode_presets(collaboration_modes_config) + .into_iter() + .find(|preset| preset.mode == Some(collaboration_mode.mode)) + .and_then(|preset| preset.developer_instructions.flatten()) + .filter(|instructions| !instructions.is_empty()) { collaboration_mode.settings.developer_instructions = Some(instructions); } @@ -893,6 +973,18 @@ impl CodexMessageProcessor { self.thread_set_name(to_connection_request_id(request_id), params) .await; } + ClientRequest::ThreadGoalSet { request_id, params } => { + self.thread_goal_set(to_connection_request_id(request_id), params) + .await; + } + ClientRequest::ThreadGoalGet { request_id, params } => { + self.thread_goal_get(to_connection_request_id(request_id), params) + .await; + } + ClientRequest::ThreadGoalClear { request_id, params } => { + self.thread_goal_clear(to_connection_request_id(request_id), params) + .await; + } ClientRequest::ThreadMetadataUpdate { request_id, params } => { self.thread_metadata_update(to_connection_request_id(request_id), params) .await; @@ -944,6 +1036,13 @@ impl CodexMessageProcessor { self.thread_shell_command(to_connection_request_id(request_id), params) .await; } + ClientRequest::ThreadApproveGuardianDeniedAction { request_id, params } => { + self.thread_approve_guardian_denied_action( + to_connection_request_id(request_id), + params, + ) + .await; + } ClientRequest::SkillsList { request_id, params } => { self.skills_list(to_connection_request_id(request_id), params) .await; @@ -956,6 +1055,10 @@ impl CodexMessageProcessor { self.marketplace_remove(to_connection_request_id(request_id), params) .await; } + ClientRequest::MarketplaceUpgrade { request_id, params } => { + self.marketplace_upgrade(to_connection_request_id(request_id), params) + .await; + } ClientRequest::PluginList { request_id, params } => { self.plugin_list(to_connection_request_id(request_id), params) .await; @@ -1255,7 +1358,7 @@ impl CodexMessageProcessor { self.config.cli_auth_credentials_store_mode, ) { Ok(()) => { - self.auth_manager.reload(); + self.auth_manager.reload().await; Ok(()) } Err(err) => Err(JSONRPCErrorError { @@ -1267,31 +1370,16 @@ impl CodexMessageProcessor { } async fn login_api_key_v2(&self, request_id: ConnectionRequestId, params: LoginApiKeyParams) { - match self.login_api_key_common(¶ms).await { - Ok(()) => { - let response = codex_app_server_protocol::LoginAccountResponse::ApiKey {}; - self.outgoing.send_response(request_id, response).await; + let result = self + .login_api_key_common(¶ms) + .await + .map(|()| LoginAccountResponse::ApiKey {}); + let logged_in = result.is_ok(); + self.outgoing.send_result(request_id, result).await; - let payload_login_completed = AccountLoginCompletedNotification { - login_id: None, - success: true, - error: None, - }; - self.outgoing - .send_server_notification(ServerNotification::AccountLoginCompleted( - payload_login_completed, - )) - .await; - - self.outgoing - .send_server_notification(ServerNotification::AccountUpdated( - self.current_account_updated_notification(), - )) - .await; - } - Err(error) => { - self.outgoing.send_error(request_id, error).await; - } + if logged_in { + self.send_login_success_notifications(/*login_id*/ None) + .await; } } @@ -1354,202 +1442,143 @@ impl CodexMessageProcessor { } async fn login_chatgpt_v2(&self, request_id: ConnectionRequestId) { - match self.login_chatgpt_common().await { - Ok(opts) => match run_login_server(opts) { - Ok(server) => { - let login_id = Uuid::new_v4(); - let shutdown_handle = server.cancel_handle(); + let result = self.login_chatgpt_response().await; + self.outgoing.send_result(request_id, result).await; + } - // Replace active login if present. - { - let mut guard = self.active_login.lock().await; - if let Some(existing) = guard.take() { - drop(existing); - } - *guard = Some(ActiveLogin::Browser { - shutdown_handle: shutdown_handle.clone(), - login_id, - }); - } + async fn login_chatgpt_response(&self) -> Result { + let opts = self.login_chatgpt_common().await?; + let server = run_login_server(opts) + .map_err(|err| internal_error(format!("failed to start login server: {err}")))?; + let login_id = Uuid::new_v4(); + let shutdown_handle = server.cancel_handle(); - // Spawn background task to monitor completion. - let outgoing_clone = self.outgoing.clone(); - let active_login = self.active_login.clone(); - let auth_manager = self.auth_manager.clone(); - let config_manager = self.config_manager.clone(); - let chatgpt_base_url = self.config.chatgpt_base_url.clone(); - let auth_url = server.auth_url.clone(); - tokio::spawn(async move { - let (success, error_msg) = match tokio::time::timeout( - LOGIN_CHATGPT_TIMEOUT, - server.block_until_done(), - ) - .await - { - Ok(Ok(())) => (true, None), - Ok(Err(err)) => (false, Some(format!("Login server error: {err}"))), - Err(_elapsed) => { - shutdown_handle.shutdown(); - (false, Some("Login timed out".to_string())) - } - }; - - let payload_v2 = AccountLoginCompletedNotification { - login_id: Some(login_id.to_string()), - success, - error: error_msg, - }; - outgoing_clone - .send_server_notification(ServerNotification::AccountLoginCompleted( - payload_v2, - )) - .await; - - if success { - auth_manager.reload(); - config_manager.replace_cloud_requirements_loader( - auth_manager.clone(), - chatgpt_base_url, - ); - config_manager - .sync_default_client_residency_requirement() - .await; - - // Notify clients with the actual current auth mode. - let auth = auth_manager.auth_cached(); - let payload_v2 = AccountUpdatedNotification { - auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode), - plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type), - }; - outgoing_clone - .send_server_notification(ServerNotification::AccountUpdated( - payload_v2, - )) - .await; - } - - // Clear the active login if it matches this attempt. It may have been replaced or cancelled. - let mut guard = active_login.lock().await; - if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) { - *guard = None; - } - }); - - let response = codex_app_server_protocol::LoginAccountResponse::Chatgpt { - login_id: login_id.to_string(), - auth_url, - }; - self.outgoing.send_response(request_id, response).await; - } - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to start login server: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - }, - Err(err) => { - self.outgoing.send_error(request_id, err).await; + // Replace active login if present. + { + let mut guard = self.active_login.lock().await; + if let Some(existing) = guard.take() { + drop(existing); } + *guard = Some(ActiveLogin::Browser { + shutdown_handle: shutdown_handle.clone(), + login_id, + }); } + + let outgoing_clone = self.outgoing.clone(); + let active_login = self.active_login.clone(); + let auth_manager = self.auth_manager.clone(); + let config_manager = self.config_manager.clone(); + let chatgpt_base_url = self.config.chatgpt_base_url.clone(); + let auth_url = server.auth_url.clone(); + tokio::spawn(async move { + let (success, error_msg) = match tokio::time::timeout( + LOGIN_CHATGPT_TIMEOUT, + server.block_until_done(), + ) + .await + { + Ok(Ok(())) => (true, None), + Ok(Err(err)) => (false, Some(format!("Login server error: {err}"))), + Err(_elapsed) => { + shutdown_handle.shutdown(); + (false, Some("Login timed out".to_string())) + } + }; + + Self::send_chatgpt_login_completion_notifications( + &outgoing_clone, + auth_manager, + config_manager, + chatgpt_base_url, + login_id, + success, + error_msg, + ) + .await; + + // Clear the active login if it matches this attempt. It may have been replaced or cancelled. + let mut guard = active_login.lock().await; + if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) { + *guard = None; + } + }); + + Ok(LoginAccountResponse::Chatgpt { + login_id: login_id.to_string(), + auth_url, + }) } async fn login_chatgpt_device_code_v2(&self, request_id: ConnectionRequestId) { - match self.login_chatgpt_common().await { - Ok(opts) => match request_device_code(&opts).await { - Ok(device_code) => { - let login_id = Uuid::new_v4(); - let cancel = CancellationToken::new(); + let result = self.login_chatgpt_device_code_response().await; + self.outgoing.send_result(request_id, result).await; + } - { - let mut guard = self.active_login.lock().await; - if let Some(existing) = guard.take() { - drop(existing); - } - *guard = Some(ActiveLogin::DeviceCode { - cancel: cancel.clone(), - login_id, - }); - } + async fn login_chatgpt_device_code_response( + &self, + ) -> Result { + let opts = self.login_chatgpt_common().await?; + let device_code = request_device_code(&opts) + .await + .map_err(Self::login_chatgpt_device_code_start_error)?; + let login_id = Uuid::new_v4(); + let cancel = CancellationToken::new(); - let verification_url = device_code.verification_url.clone(); - let user_code = device_code.user_code.clone(); - let response = - codex_app_server_protocol::LoginAccountResponse::ChatgptDeviceCode { - login_id: login_id.to_string(), - verification_url, - user_code, - }; - self.outgoing.send_response(request_id, response).await; - - let outgoing_clone = self.outgoing.clone(); - let active_login = self.active_login.clone(); - let auth_manager = self.auth_manager.clone(); - let config_manager = self.config_manager.clone(); - let chatgpt_base_url = self.config.chatgpt_base_url.clone(); - tokio::spawn(async move { - let (success, error_msg) = tokio::select! { - _ = cancel.cancelled() => { - (false, Some("Login was not completed".to_string())) - } - r = complete_device_code_login(opts, device_code) => { - match r { - Ok(()) => (true, None), - Err(err) => (false, Some(err.to_string())), - } - } - }; - - let payload_v2 = AccountLoginCompletedNotification { - login_id: Some(login_id.to_string()), - success, - error: error_msg, - }; - outgoing_clone - .send_server_notification(ServerNotification::AccountLoginCompleted( - payload_v2, - )) - .await; - - if success { - auth_manager.reload(); - config_manager.replace_cloud_requirements_loader( - auth_manager.clone(), - chatgpt_base_url, - ); - config_manager - .sync_default_client_residency_requirement() - .await; - - let auth = auth_manager.auth_cached(); - let payload_v2 = AccountUpdatedNotification { - auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode), - plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type), - }; - outgoing_clone - .send_server_notification(ServerNotification::AccountUpdated( - payload_v2, - )) - .await; - } - - let mut guard = active_login.lock().await; - if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) { - *guard = None; - } - }); - } - Err(err) => { - let error = Self::login_chatgpt_device_code_start_error(err); - self.outgoing.send_error(request_id, error).await; - } - }, - Err(err) => { - self.outgoing.send_error(request_id, err).await; + { + let mut guard = self.active_login.lock().await; + if let Some(existing) = guard.take() { + drop(existing); } + *guard = Some(ActiveLogin::DeviceCode { + cancel: cancel.clone(), + login_id, + }); } + + let verification_url = device_code.verification_url.clone(); + let user_code = device_code.user_code.clone(); + + let outgoing_clone = self.outgoing.clone(); + let active_login = self.active_login.clone(); + let auth_manager = self.auth_manager.clone(); + let config_manager = self.config_manager.clone(); + let chatgpt_base_url = self.config.chatgpt_base_url.clone(); + tokio::spawn(async move { + let (success, error_msg) = tokio::select! { + _ = cancel.cancelled() => { + (false, Some("Login was not completed".to_string())) + } + r = complete_device_code_login(opts, device_code) => { + match r { + Ok(()) => (true, None), + Err(err) => (false, Some(err.to_string())), + } + } + }; + + Self::send_chatgpt_login_completion_notifications( + &outgoing_clone, + auth_manager, + config_manager, + chatgpt_base_url, + login_id, + success, + error_msg, + ) + .await; + + let mut guard = active_login.lock().await; + if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) { + *guard = None; + } + }); + + Ok(LoginAccountResponse::ChatgptDeviceCode { + login_id: login_id.to_string(), + verification_url, + user_code, + }) } async fn cancel_login_chatgpt_common( @@ -1572,25 +1601,22 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: CancelLoginAccountParams, ) { + let result = self.cancel_login_response(params).await; + self.outgoing.send_result(request_id, result).await; + } + + async fn cancel_login_response( + &self, + params: CancelLoginAccountParams, + ) -> Result { let login_id = params.login_id; - match Uuid::parse_str(&login_id) { - Ok(uuid) => { - let status = match self.cancel_login_chatgpt_common(uuid).await { - Ok(()) => CancelLoginAccountStatus::Canceled, - Err(CancelLoginError::NotFound) => CancelLoginAccountStatus::NotFound, - }; - let response = CancelLoginAccountResponse { status }; - self.outgoing.send_response(request_id, response).await; - } - Err(_) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid login id: {login_id}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - } + let uuid = Uuid::parse_str(&login_id) + .map_err(|_| invalid_request(format!("invalid login id: {login_id}")))?; + let status = match self.cancel_login_chatgpt_common(uuid).await { + Ok(()) => CancelLoginAccountStatus::Canceled, + Err(CancelLoginError::NotFound) => CancelLoginAccountStatus::NotFound, + }; + Ok(CancelLoginAccountResponse { status }) } async fn login_chatgpt_auth_tokens( @@ -1600,18 +1626,31 @@ impl CodexMessageProcessor { chatgpt_account_id: String, chatgpt_plan_type: Option, ) { + let result = self + .login_chatgpt_auth_tokens_response(access_token, chatgpt_account_id, chatgpt_plan_type) + .await; + let logged_in = result.is_ok(); + self.outgoing.send_result(request_id, result).await; + + if logged_in { + self.send_login_success_notifications(/*login_id*/ None) + .await; + } + } + + async fn login_chatgpt_auth_tokens_response( + &self, + access_token: String, + chatgpt_account_id: String, + chatgpt_plan_type: Option, + ) -> Result { if matches!( self.config.forced_login_method, Some(ForcedLoginMethod::Api) ) { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "External ChatGPT auth is disabled. Use API key login instead." - .to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; + return Err(invalid_request( + "External ChatGPT auth is disabled. Use API key login instead.", + )); } // Cancel any active login attempt to avoid persisting managed auth state. @@ -1625,32 +1664,19 @@ impl CodexMessageProcessor { if let Some(expected_workspace) = self.config.forced_chatgpt_workspace_id.as_deref() && chatgpt_account_id != expected_workspace { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!( - "External auth must use workspace {expected_workspace}, but received {chatgpt_account_id:?}." - ), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; + return Err(invalid_request(format!( + "External auth must use workspace {expected_workspace}, but received {chatgpt_account_id:?}." + ))); } - if let Err(err) = login_with_chatgpt_auth_tokens( + login_with_chatgpt_auth_tokens( &self.config.codex_home, &access_token, &chatgpt_account_id, chatgpt_plan_type.as_deref(), - ) { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to set external auth: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - self.auth_manager.reload(); + ) + .map_err(|err| internal_error(format!("failed to set external auth: {err}")))?; + self.auth_manager.reload().await; self.config_manager.replace_cloud_requirements_loader( self.auth_manager.clone(), self.config.chatgpt_base_url.clone(), @@ -1659,12 +1685,12 @@ impl CodexMessageProcessor { .sync_default_client_residency_requirement() .await; - self.outgoing - .send_response(request_id, LoginAccountResponse::ChatgptAuthTokens {}) - .await; + Ok(LoginAccountResponse::ChatgptAuthTokens {}) + } + async fn send_login_success_notifications(&self, login_id: Option) { let payload_login_completed = AccountLoginCompletedNotification { - login_id: None, + login_id: login_id.map(|id| id.to_string()), success: true, error: None, }; @@ -1681,6 +1707,43 @@ impl CodexMessageProcessor { .await; } + async fn send_chatgpt_login_completion_notifications( + outgoing: &OutgoingMessageSender, + auth_manager: Arc, + config_manager: ConfigManager, + chatgpt_base_url: String, + login_id: Uuid, + success: bool, + error_msg: Option, + ) { + let payload_v2 = AccountLoginCompletedNotification { + login_id: Some(login_id.to_string()), + success, + error: error_msg, + }; + outgoing + .send_server_notification(ServerNotification::AccountLoginCompleted(payload_v2)) + .await; + + if success { + auth_manager.reload().await; + config_manager + .replace_cloud_requirements_loader(auth_manager.clone(), chatgpt_base_url); + config_manager + .sync_default_client_residency_requirement() + .await; + + let auth = auth_manager.auth_cached(); + let payload_v2 = AccountUpdatedNotification { + auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode), + plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type), + }; + outgoing + .send_server_notification(ServerNotification::AccountUpdated(payload_v2)) + .await; + } + } + async fn logout_common(&self) -> std::result::Result, JSONRPCErrorError> { // Cancel any active login attempt. { @@ -1710,23 +1773,24 @@ impl CodexMessageProcessor { } async fn logout_v2(&self, request_id: ConnectionRequestId) { - match self.logout_common().await { - Ok(current_auth_method) => { - self.outgoing - .send_response(request_id, LogoutAccountResponse {}) - .await; - - let payload_v2 = AccountUpdatedNotification { - auth_mode: current_auth_method, + let result = self.logout_common().await; + let account_updated = + result + .as_ref() + .ok() + .cloned() + .map(|auth_mode| AccountUpdatedNotification { + auth_mode, plan_type: None, - }; - self.outgoing - .send_server_notification(ServerNotification::AccountUpdated(payload_v2)) - .await; - } - Err(error) => { - self.outgoing.send_error(request_id, error).await; - } + }); + self.outgoing + .send_result(request_id, result.map(|_| LogoutAccountResponse {})) + .await; + + if let Some(payload) = account_updated { + self.outgoing + .send_server_notification(ServerNotification::AccountUpdated(payload)) + .await; } } @@ -1774,7 +1838,9 @@ impl CodexMessageProcessor { self.auth_manager.refresh_failure_for_auth(&auth).is_some(); let auth_mode = auth.api_auth_mode(); let (reported_auth_method, token_opt) = - if include_token && permanent_refresh_failure { + if matches!(auth, CodexAuth::AgentIdentity(_)) + || include_token && permanent_refresh_failure + { (Some(auth_mode), None) } else { match auth.get_token() { @@ -1807,75 +1873,54 @@ impl CodexMessageProcessor { } async fn get_account(&self, request_id: ConnectionRequestId, params: GetAccountParams) { + let result = self.get_account_response(params).await; + self.outgoing.send_result(request_id, result).await; + } + + async fn get_account_response( + &self, + params: GetAccountParams, + ) -> Result { let do_refresh = params.refresh_token; self.refresh_token_if_requested(do_refresh).await; - // Whether auth is required for the active model provider. - let requires_openai_auth = self.config.model_provider.requires_openai_auth; - - if !requires_openai_auth { - let response = GetAccountResponse { - account: None, - requires_openai_auth, - }; - self.outgoing.send_response(request_id, response).await; - return; - } - - let account = match self.auth_manager.auth_cached() { - Some(auth) => match auth.auth_mode() { - CoreAuthMode::ApiKey => Some(Account::ApiKey {}), - CoreAuthMode::Chatgpt | CoreAuthMode::ChatgptAuthTokens => { - let email = auth.get_account_email(); - let plan_type = auth.account_plan_type(); - - match (email, plan_type) { - (Some(email), Some(plan_type)) => { - Some(Account::Chatgpt { email, plan_type }) - } - _ => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: - "email and plan type are required for chatgpt authentication" - .to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - } - } - }, - None => None, + let provider = create_model_provider( + self.config.model_provider.clone(), + Some(self.auth_manager.clone()), + ); + let account_state = match provider.account_state() { + Ok(account_state) => account_state, + Err(ProviderAccountError::MissingChatgptAccountDetails) => { + return Err(invalid_request( + "email and plan type are required for chatgpt authentication", + )); + } }; + let account = account_state.account.map(Account::from); - let response = GetAccountResponse { + Ok(GetAccountResponse { account, - requires_openai_auth, - }; - self.outgoing.send_response(request_id, response).await; + requires_openai_auth: account_state.requires_openai_auth, + }) } async fn get_account_rate_limits(&self, request_id: ConnectionRequestId) { - match self.fetch_account_rate_limits().await { - Ok((rate_limits, rate_limits_by_limit_id)) => { - let response = GetAccountRateLimitsResponse { - rate_limits: rate_limits.into(), - rate_limits_by_limit_id: Some( - rate_limits_by_limit_id - .into_iter() - .map(|(limit_id, snapshot)| (limit_id, snapshot.into())) - .collect(), - ), - }; - self.outgoing.send_response(request_id, response).await; - } - Err(error) => { - self.outgoing.send_error(request_id, error).await; - } - } + let result = + self.fetch_account_rate_limits() + .await + .map( + |(rate_limits, rate_limits_by_limit_id)| GetAccountRateLimitsResponse { + rate_limits: rate_limits.into(), + rate_limits_by_limit_id: Some( + rate_limits_by_limit_id + .into_iter() + .map(|(limit_id, snapshot)| (limit_id, snapshot.into())) + .collect(), + ), + }, + ); + self.outgoing.send_result(request_id, result).await; } async fn send_add_credits_nudge_email( @@ -1883,16 +1928,11 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: SendAddCreditsNudgeEmailParams, ) { - match self.send_add_credits_nudge_email_inner(params).await { - Ok(status) => { - self.outgoing - .send_response(request_id, SendAddCreditsNudgeEmailResponse { status }) - .await; - } - Err(error) => { - self.outgoing.send_error(request_id, error).await; - } - } + let result = self + .send_add_credits_nudge_email_inner(params) + .await + .map(|status| SendAddCreditsNudgeEmailResponse { status }); + self.outgoing.send_result(request_id, result).await; } async fn send_add_credits_nudge_email_inner( @@ -1908,7 +1948,7 @@ impl CodexMessageProcessor { }); }; - if !auth.is_chatgpt_auth() { + if !auth.uses_codex_backend() { return Err(JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, message: "chatgpt authentication required to notify workspace owner".to_string(), @@ -1963,7 +2003,7 @@ impl CodexMessageProcessor { }); }; - if !auth.is_chatgpt_auth() { + if !auth.uses_codex_backend() { return Err(JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, message: "chatgpt authentication required to read rate limits".to_string(), @@ -2020,18 +2060,24 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: CommandExecParams, ) { + let result = self + .exec_one_off_command_inner(request_id.clone(), params) + .await + .map(|()| None::); + self.send_optional_result(request_id, result).await; + } + + async fn exec_one_off_command_inner( + &self, + request_id: ConnectionRequestId, + params: CommandExecParams, + ) -> Result<(), JSONRPCErrorError> { tracing::debug!("ExecOneOffCommand params: {params:?}"); let request = request_id.clone(); if params.command.is_empty() { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "command must not be empty".to_string(), - data: None, - }; - self.outgoing.send_error(request, error).await; - return; + return Err(invalid_request("command must not be empty")); } let CommandExecParams { @@ -2048,37 +2094,28 @@ impl CodexMessageProcessor { env: env_overrides, size, sandbox_policy, + permission_profile, } = params; + if sandbox_policy.is_some() && permission_profile.is_some() { + return Err(invalid_request( + "`permissionProfile` cannot be combined with `sandboxPolicy`", + )); + } if size.is_some() && !tty { - let error = JSONRPCErrorError { - code: INVALID_PARAMS_ERROR_CODE, - message: "command/exec size requires tty: true".to_string(), - data: None, - }; - self.outgoing.send_error(request, error).await; - return; + return Err(invalid_params("command/exec size requires tty: true")); } if disable_output_cap && output_bytes_cap.is_some() { - let error = JSONRPCErrorError { - code: INVALID_PARAMS_ERROR_CODE, - message: "command/exec cannot set both outputBytesCap and disableOutputCap" - .to_string(), - data: None, - }; - self.outgoing.send_error(request, error).await; - return; + return Err(invalid_params( + "command/exec cannot set both outputBytesCap and disableOutputCap", + )); } if disable_timeout && timeout_ms.is_some() { - let error = JSONRPCErrorError { - code: INVALID_PARAMS_ERROR_CODE, - message: "command/exec cannot set both timeoutMs and disableTimeout".to_string(), - data: None, - }; - self.outgoing.send_error(request, error).await; - return; + return Err(invalid_params( + "command/exec cannot set both timeoutMs and disableTimeout", + )); } let cwd = cwd.map_or_else(|| self.config.cwd.clone(), |cwd| self.config.cwd.join(cwd)); @@ -2102,15 +2139,9 @@ impl CodexMessageProcessor { Some(timeout_ms) => match u64::try_from(timeout_ms) { Ok(timeout_ms) => Some(timeout_ms), Err(_) => { - let error = JSONRPCErrorError { - code: INVALID_PARAMS_ERROR_CODE, - message: format!( - "command/exec timeoutMs must be non-negative, got {timeout_ms}" - ), - data: None, - }; - self.outgoing.send_error(request, error).await; - return; + return Err(invalid_params(format!( + "command/exec timeoutMs must be non-negative, got {timeout_ms}" + ))); } }, None => None, @@ -2120,7 +2151,7 @@ impl CodexMessageProcessor { let started_network_proxy = match self.config.permissions.network.as_ref() { Some(spec) => match spec .start_proxy( - self.config.permissions.sandbox_policy.get(), + self.config.permissions.permission_profile.get(), /*policy_decider*/ None, /*blocked_request_observer*/ None, managed_network_requirements_enabled, @@ -2130,13 +2161,9 @@ impl CodexMessageProcessor { { Ok(started) => Some(started), Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to start managed network proxy: {err}"), - data: None, - }; - self.outgoing.send_error(request, error).await; - return; + return Err(internal_error(format!( + "failed to start managed network proxy: {err}" + ))); } }, None => None, @@ -2160,7 +2187,11 @@ impl CodexMessageProcessor { } else { ExecCapturePolicy::ShellTool }; - let sandbox_cwd = self.config.cwd.clone(); + let sandbox_cwd = if permission_profile.is_some() { + cwd.clone() + } else { + self.config.cwd.clone() + }; let exec_params = ExecParams { command, cwd: cwd.clone(), @@ -2180,35 +2211,52 @@ impl CodexMessageProcessor { arg0: None, }; - let requested_policy = sandbox_policy.map(|policy| policy.to_core()); - let ( - effective_policy, - effective_file_system_sandbox_policy, - effective_network_sandbox_policy, - ) = match requested_policy { - Some(policy) => match self.config.permissions.sandbox_policy.can_set(&policy) { - Ok(()) => { - let file_system_sandbox_policy = - codex_protocol::permissions::FileSystemSandboxPolicy::from_legacy_sandbox_policy(&policy, &sandbox_cwd); - let network_sandbox_policy = - codex_protocol::permissions::NetworkSandboxPolicy::from(&policy); - (policy, file_system_sandbox_policy, network_sandbox_policy) - } - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid sandbox policy: {err}"), - data: None, - }; - self.outgoing.send_error(request, error).await; - return; - } - }, - None => ( - self.config.permissions.sandbox_policy.get().clone(), - self.config.permissions.file_system_sandbox_policy.clone(), - self.config.permissions.network_sandbox_policy, - ), + let effective_permission_profile = if let Some(permission_profile) = permission_profile { + let permission_profile = + codex_protocol::models::PermissionProfile::from(permission_profile); + let (mut file_system_sandbox_policy, network_sandbox_policy) = + permission_profile.to_runtime_permissions(); + let configured_file_system_sandbox_policy = + self.config.permissions.file_system_sandbox_policy(); + Self::preserve_configured_deny_read_restrictions( + &mut file_system_sandbox_policy, + &configured_file_system_sandbox_policy, + ); + let effective_permission_profile = + codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement( + permission_profile.enforcement(), + &file_system_sandbox_policy, + network_sandbox_policy, + ); + self.config + .permissions + .permission_profile + .can_set(&effective_permission_profile) + .map_err(|err| invalid_request(format!("invalid permission profile: {err}")))?; + effective_permission_profile + } else if let Some(policy) = sandbox_policy.map(|policy| policy.to_core()) { + self.config + .permissions + .can_set_legacy_sandbox_policy(&policy, &sandbox_cwd) + .map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?; + let file_system_sandbox_policy = + codex_protocol::permissions::FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&policy, &sandbox_cwd); + let network_sandbox_policy = + codex_protocol::permissions::NetworkSandboxPolicy::from(&policy); + let permission_profile = + codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement( + codex_protocol::models::SandboxEnforcement::from_legacy_sandbox_policy(&policy), + &file_system_sandbox_policy, + network_sandbox_policy, + ); + self.config + .permissions + .permission_profile + .can_set(&permission_profile) + .map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?; + permission_profile + } else { + self.config.permissions.permission_profile() }; let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); @@ -2218,51 +2266,40 @@ impl CodexMessageProcessor { let use_legacy_landlock = self.config.features.use_legacy_landlock(); let size = match size.map(crate::command_exec::terminal_size_from_protocol) { Some(Ok(size)) => Some(size), - Some(Err(error)) => { - self.outgoing.send_error(request, error).await; - return; - } + Some(Err(error)) => return Err(error), None => None, }; - match codex_core::exec::build_exec_request( + let exec_request = codex_core::exec::build_exec_request( exec_params, - &effective_policy, - &effective_file_system_sandbox_policy, - effective_network_sandbox_policy, + &effective_permission_profile, &sandbox_cwd, &codex_linux_sandbox_exe, use_legacy_landlock, - ) { - Ok(exec_request) => { - if let Err(error) = self - .command_exec_manager - .start(StartCommandExecParams { - outgoing, - request_id: request_for_task, - process_id, - exec_request, - started_network_proxy: started_network_proxy_for_task, - tty, - stream_stdin, - stream_stdout_stderr, - output_bytes_cap, - size, - }) - .await - { - self.outgoing.send_error(request, error).await; - } - } - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("exec failed: {err}"), - data: None, - }; - self.outgoing.send_error(request, error).await; - } - } + ) + .map_err(|err| internal_error(format!("exec failed: {err}")))?; + self.command_exec_manager + .start(StartCommandExecParams { + outgoing, + request_id: request_for_task, + process_id, + exec_request, + started_network_proxy: started_network_proxy_for_task, + tty, + stream_stdin, + stream_stdout_stderr, + output_bytes_cap, + size, + }) + .await + } + + fn preserve_configured_deny_read_restrictions( + file_system_sandbox_policy: &mut FileSystemSandboxPolicy, + configured_file_system_sandbox_policy: &FileSystemSandboxPolicy, + ) { + file_system_sandbox_policy + .preserve_deny_read_restrictions_from(configured_file_system_sandbox_policy); } async fn command_exec_write( @@ -2270,14 +2307,11 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: CommandExecWriteParams, ) { - match self + let result = self .command_exec_manager .write(request_id.clone(), params) - .await - { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, - } + .await; + self.outgoing.send_result(request_id, result).await; } async fn command_exec_resize( @@ -2285,14 +2319,11 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: CommandExecResizeParams, ) { - match self + let result = self .command_exec_manager .resize(request_id.clone(), params) - .await - { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, - } + .await; + self.outgoing.send_result(request_id, result).await; } async fn command_exec_terminate( @@ -2300,14 +2331,11 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: CommandExecTerminateParams, ) { - match self + let result = self .command_exec_manager .terminate(request_id.clone(), params) - .await - { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, - } + .await; + self.outgoing.send_result(request_id, result).await; } async fn thread_start( @@ -2326,6 +2354,7 @@ impl CodexMessageProcessor { approval_policy, approvals_reviewer, sandbox, + permission_profile, config, service_name, base_instructions, @@ -2336,8 +2365,40 @@ impl CodexMessageProcessor { personality, ephemeral, session_start_source, + environments, persist_extended_history, } = params; + if sandbox.is_some() && permission_profile.is_some() { + self.outgoing + .send_error( + request_id, + invalid_request("`permissionProfile` cannot be combined with `sandbox`"), + ) + .await; + return; + } + let environments = environments.map(|environments| { + environments + .into_iter() + .map(|environment| TurnEnvironmentSelection { + environment_id: environment.environment_id, + cwd: environment.cwd, + }) + .collect::>() + }); + if let Some(environments) = environments.as_ref() + && let Err(err) = self + .thread_manager + .validate_environment_selections(environments) + { + self.outgoing + .send_error( + request_id, + invalid_request(environment_selection_error_message(err)), + ) + .await; + return; + } let mut typesafe_overrides = self.build_thread_config_overrides( model, model_provider, @@ -2346,6 +2407,7 @@ impl CodexMessageProcessor { approval_policy, approvals_reviewer, sandbox, + permission_profile, base_instructions, developer_instructions, personality, @@ -2357,7 +2419,6 @@ impl CodexMessageProcessor { outgoing: Arc::clone(&self.outgoing), pending_thread_unloads: Arc::clone(&self.pending_thread_unloads), analytics_events_client: self.analytics_events_client.clone(), - general_analytics_enabled: self.config.features.enabled(Feature::GeneralAnalytics), thread_watch_manager: self.thread_watch_manager.clone(), fallback_model_provider: self.config.model_provider_id.clone(), codex_home: self.config.codex_home.to_path_buf(), @@ -2375,6 +2436,7 @@ impl CodexMessageProcessor { typesafe_overrides, dynamic_tools, session_start_source, + environments, persist_extended_history, service_name, experimental_raw_events, @@ -2449,259 +2511,238 @@ impl CodexMessageProcessor { typesafe_overrides: ConfigOverrides, dynamic_tools: Option>, session_start_source: Option, + environments: Option>, persist_extended_history: bool, service_name: Option, experimental_raw_events: bool, request_trace: Option, ) { - let requested_cwd = typesafe_overrides.cwd.clone(); - let mut config = match config_manager - .load_with_overrides(config_overrides.clone(), typesafe_overrides.clone()) - .await - { - Ok(config) => config, - Err(err) => { - let error = config_load_error(&err); - listener_task_context - .outgoing - .send_error(request_id, error) - .await; - return; - } - }; - - // The user may have requested WorkspaceWrite or DangerFullAccess via - // the command line, though in the process of deriving the Config, it - // could be downgraded to ReadOnly (perhaps there is no sandbox - // available on Windows or the enterprise config disallows it). The cwd - // should still be considered "trusted" in this case. - let requested_sandbox_trusts_project = matches!( - typesafe_overrides.sandbox_mode, - Some( - codex_protocol::config_types::SandboxMode::WorkspaceWrite - | codex_protocol::config_types::SandboxMode::DangerFullAccess - ) - ); - - if requested_cwd.is_some() - && !config.active_project.is_trusted() - && (requested_sandbox_trusts_project - || matches!( - config.permissions.sandbox_policy.get(), - codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } - | codex_protocol::protocol::SandboxPolicy::DangerFullAccess - | codex_protocol::protocol::SandboxPolicy::ExternalSandbox { .. } - )) - { - let trust_target = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &config.cwd) + let result = async { + let requested_cwd = typesafe_overrides.cwd.clone(); + let mut config = config_manager + .load_with_overrides(config_overrides.clone(), typesafe_overrides.clone()) .await - .unwrap_or_else(|| config.cwd.clone()); - let current_cli_overrides = config_manager.current_cli_overrides(); - let cli_overrides_with_trust; - let cli_overrides_for_reload = if let Err(err) = - codex_core::config::set_project_trust_level( - &listener_task_context.codex_home, - trust_target.as_path(), - TrustLevel::Trusted, - ) { - warn!( - "failed to persist trusted project state for {}; continuing with in-memory trust for this thread: {err}", - trust_target.display() - ); - let mut project = toml::map::Map::new(); - project.insert( - "trust_level".to_string(), - TomlValue::String("trusted".to_string()), - ); - let mut projects = toml::map::Map::new(); - projects.insert( - project_trust_key(trust_target.as_path()), - TomlValue::Table(project), - ); - cli_overrides_with_trust = current_cli_overrides - .iter() - .cloned() - .chain(std::iter::once(( - "projects".to_string(), - TomlValue::Table(projects), - ))) - .collect::>(); - cli_overrides_with_trust.as_slice() - } else { - current_cli_overrides.as_slice() - }; + .map_err(|err| config_load_error(&err))?; - config = match config_manager - .load_with_cli_overrides( - cli_overrides_for_reload, - config_overrides, - typesafe_overrides, - /*fallback_cwd*/ None, - ) - .await + // The user may have requested WorkspaceWrite or DangerFullAccess via + // the command line, though in the process of deriving the Config, it + // could be downgraded to ReadOnly (perhaps there is no sandbox + // available on Windows or the enterprise config disallows it). The cwd + // should still be considered "trusted" in this case. + let requested_permissions_trust_project = + requested_permissions_trust_project(&typesafe_overrides, config.cwd.as_path()); + let effective_permissions_trust_project = permission_profile_trusts_project( + &config.permissions.permission_profile(), + config.cwd.as_path(), + ); + + if requested_cwd.is_some() + && config.active_project.trust_level.is_none() + && (requested_permissions_trust_project || effective_permissions_trust_project) { - Ok(config) => config, - Err(err) => { - let error = config_load_error(&err); - listener_task_context - .outgoing - .send_error(request_id, error) - .await; - return; - } - }; - } + let trust_target = + resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &config.cwd) + .await + .unwrap_or_else(|| config.cwd.clone()); + let current_cli_overrides = config_manager.current_cli_overrides(); + let cli_overrides_with_trust; + let cli_overrides_for_reload = + if let Err(err) = codex_core::config::set_project_trust_level( + &listener_task_context.codex_home, + trust_target.as_path(), + TrustLevel::Trusted, + ) { + warn!( + "failed to persist trusted project state for {}; continuing with in-memory trust for this thread: {err}", + trust_target.display() + ); + let mut project = toml::map::Map::new(); + project.insert( + "trust_level".to_string(), + TomlValue::String("trusted".to_string()), + ); + let mut projects = toml::map::Map::new(); + projects.insert( + project_trust_key(trust_target.as_path()), + TomlValue::Table(project), + ); + cli_overrides_with_trust = current_cli_overrides + .iter() + .cloned() + .chain(std::iter::once(( + "projects".to_string(), + TomlValue::Table(projects), + ))) + .collect::>(); + cli_overrides_with_trust.as_slice() + } else { + current_cli_overrides.as_slice() + }; - let instruction_sources = Self::instruction_sources_from_config(&config).await; - let dynamic_tools = dynamic_tools.unwrap_or_default(); - let core_dynamic_tools = if dynamic_tools.is_empty() { - Vec::new() - } else { - if let Err(message) = validate_dynamic_tools(&dynamic_tools) { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message, - data: None, - }; - listener_task_context - .outgoing - .send_error(request_id, error) - .await; - return; - } - dynamic_tools - .into_iter() - .map(|tool| CoreDynamicToolSpec { - namespace: tool.namespace, - name: tool.name, - description: tool.description, - input_schema: tool.input_schema, - defer_loading: tool.defer_loading, - }) - .collect() - }; - let core_dynamic_tool_count = core_dynamic_tools.len(); - - match listener_task_context - .thread_manager - .start_thread_with_tools_and_service_name( - config, - match session_start_source - .unwrap_or(codex_app_server_protocol::ThreadStartSource::Startup) - { - codex_app_server_protocol::ThreadStartSource::Startup => InitialHistory::New, - codex_app_server_protocol::ThreadStartSource::Clear => InitialHistory::Cleared, - }, - core_dynamic_tools, - persist_extended_history, - service_name, - request_trace, - ) - .instrument(tracing::info_span!( - "app_server.thread_start.create_thread", - otel.name = "app_server.thread_start.create_thread", - thread_start.dynamic_tool_count = core_dynamic_tool_count, - thread_start.persist_extended_history = persist_extended_history, - )) - .await - { - Ok(new_conv) => { - let NewThread { - thread_id, - thread, - session_configured, - .. - } = new_conv; - if let Err(error) = Self::set_app_server_client_info( - thread.as_ref(), - app_server_client_name, - app_server_client_version, - ) - .await - { - listener_task_context - .outgoing - .send_error(request_id, error) - .await; - return; - } - let config_snapshot = thread - .config_snapshot() - .instrument(tracing::info_span!( - "app_server.thread_start.config_snapshot", - otel.name = "app_server.thread_start.config_snapshot", - )) - .await; - let mut thread = build_thread_from_snapshot( - thread_id, - &config_snapshot, - session_configured.rollout_path.clone(), - ); - - // Auto-attach a thread listener when starting a thread. - Self::log_listener_attach_result( - Self::ensure_conversation_listener_task( - listener_task_context.clone(), - thread_id, - request_id.connection_id, - experimental_raw_events, - ApiVersion::V2, + config = config_manager + .load_with_cli_overrides( + cli_overrides_for_reload, + config_overrides, + typesafe_overrides, + /*fallback_cwd*/ None, ) - .instrument(tracing::info_span!( - "app_server.thread_start.attach_listener", - otel.name = "app_server.thread_start.attach_listener", - thread_start.experimental_raw_events = experimental_raw_events, - )) - .await, + .await + .map_err(|err| config_load_error(&err))?; + } + + let instruction_sources = Self::instruction_sources_from_config(&config).await; + let environments = environments.unwrap_or_else(|| { + listener_task_context + .thread_manager + .default_environment_selections(&config.cwd) + }); + let dynamic_tools = dynamic_tools.unwrap_or_default(); + let core_dynamic_tools = if dynamic_tools.is_empty() { + Vec::new() + } else { + validate_dynamic_tools(&dynamic_tools).map_err(invalid_request)?; + dynamic_tools + .into_iter() + .map(|tool| CoreDynamicToolSpec { + namespace: tool.namespace, + name: tool.name, + description: tool.description, + input_schema: tool.input_schema, + defer_loading: tool.defer_loading, + }) + .collect() + }; + let core_dynamic_tool_count = core_dynamic_tools.len(); + + let NewThread { + thread_id, + thread, + session_configured, + .. + } = listener_task_context + .thread_manager + .start_thread_with_tools_and_service_name(StartThreadWithToolsOptions { + config, + initial_history: match session_start_source + .unwrap_or(codex_app_server_protocol::ThreadStartSource::Startup) + { + codex_app_server_protocol::ThreadStartSource::Startup => { + InitialHistory::New + } + codex_app_server_protocol::ThreadStartSource::Clear => { + InitialHistory::Cleared + } + }, + dynamic_tools: core_dynamic_tools, + persist_extended_history, + metrics_service_name: service_name, + parent_trace: request_trace, + environments, + }) + .instrument(tracing::info_span!( + "app_server.thread_start.create_thread", + otel.name = "app_server.thread_start.create_thread", + thread_start.dynamic_tool_count = core_dynamic_tool_count, + thread_start.persist_extended_history = persist_extended_history, + )) + .await + .map_err(|err| match err { + CodexErr::InvalidRequest(message) => invalid_request(message), + err => internal_error(format!("error creating thread: {err}")), + })?; + + Self::set_app_server_client_info( + thread.as_ref(), + app_server_client_name, + app_server_client_version, + ) + .await?; + + let config_snapshot = thread + .config_snapshot() + .instrument(tracing::info_span!( + "app_server.thread_start.config_snapshot", + otel.name = "app_server.thread_start.config_snapshot", + )) + .await; + let mut thread = build_thread_from_snapshot( + thread_id, + &config_snapshot, + session_configured.rollout_path.clone(), + ); + + // Auto-attach a thread listener when starting a thread. + Self::log_listener_attach_result( + Self::ensure_conversation_listener_task( + listener_task_context.clone(), thread_id, request_id.connection_id, - "thread", - ); + experimental_raw_events, + ApiVersion::V2, + ) + .instrument(tracing::info_span!( + "app_server.thread_start.attach_listener", + otel.name = "app_server.thread_start.attach_listener", + thread_start.experimental_raw_events = experimental_raw_events, + )) + .await, + thread_id, + request_id.connection_id, + "thread", + ); + listener_task_context + .thread_watch_manager + .upsert_thread_silently(thread.clone()) + .instrument(tracing::info_span!( + "app_server.thread_start.upsert_thread", + otel.name = "app_server.thread_start.upsert_thread", + )) + .await; + + thread.status = resolve_thread_status( listener_task_context .thread_watch_manager - .upsert_thread_silently(thread.clone()) + .loaded_status_for_thread(&thread.id) .instrument(tracing::info_span!( - "app_server.thread_start.upsert_thread", - otel.name = "app_server.thread_start.upsert_thread", + "app_server.thread_start.resolve_status", + otel.name = "app_server.thread_start.resolve_status", )) - .await; + .await, + /*has_in_progress_turn*/ false, + ); - thread.status = resolve_thread_status( - listener_task_context - .thread_watch_manager - .loaded_status_for_thread(&thread.id) - .instrument(tracing::info_span!( - "app_server.thread_start.resolve_status", - otel.name = "app_server.thread_start.resolve_status", - )) - .await, - /*has_in_progress_turn*/ false, - ); + let permission_profile = + thread_response_permission_profile(config_snapshot.permission_profile); - let response = ThreadStartResponse { - thread: thread.clone(), - model: config_snapshot.model, - model_provider: config_snapshot.model_provider_id, - service_tier: config_snapshot.service_tier, - cwd: config_snapshot.cwd, - instruction_sources, - approval_policy: config_snapshot.approval_policy.into(), - approvals_reviewer: config_snapshot.approvals_reviewer.into(), - sandbox: config_snapshot.sandbox_policy.into(), - reasoning_effort: config_snapshot.reasoning_effort, - }; - if listener_task_context.general_analytics_enabled { - listener_task_context - .analytics_events_client - .track_response( - request_id.connection_id.0, - ClientResponse::ThreadStart { - request_id: request_id.request_id.clone(), - response: response.clone(), - }, - ); - } + let response = ThreadStartResponse { + thread: thread.clone(), + model: config_snapshot.model, + model_provider: config_snapshot.model_provider_id, + service_tier: config_snapshot.service_tier, + cwd: config_snapshot.cwd, + instruction_sources, + approval_policy: config_snapshot.approval_policy.into(), + approvals_reviewer: config_snapshot.approvals_reviewer.into(), + sandbox: config_snapshot.sandbox_policy.into(), + permission_profile, + reasoning_effort: config_snapshot.reasoning_effort, + }; + Ok::<_, JSONRPCErrorError>((response, thread_started_notification(thread))) + } + .await; + + match result { + Ok((response, notif)) => { + listener_task_context + .analytics_events_client + .track_response( + request_id.connection_id.0, + ClientResponse::ThreadStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); listener_task_context .outgoing @@ -2712,7 +2753,6 @@ impl CodexMessageProcessor { )) .await; - let notif = ThreadStartedNotification { thread }; listener_task_context .outgoing .send_server_notification(ServerNotification::ThreadStarted(notif)) @@ -2722,12 +2762,7 @@ impl CodexMessageProcessor { )) .await; } - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("error creating thread: {err}"), - data: None, - }; + Err(error) => { listener_task_context .outgoing .send_error(request_id, error) @@ -2746,6 +2781,7 @@ impl CodexMessageProcessor { approval_policy: Option, approvals_reviewer: Option, sandbox: Option, + permission_profile: Option, base_instructions: Option, developer_instructions: Option, personality: Option, @@ -2760,6 +2796,7 @@ impl CodexMessageProcessor { approvals_reviewer: approvals_reviewer .map(codex_app_server_protocol::ApprovalsReviewer::to_core), sandbox_mode: sandbox.map(SandboxMode::to_core), + permission_profile: permission_profile.map(Into::into), codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), base_instructions, @@ -2770,39 +2807,42 @@ impl CodexMessageProcessor { } async fn thread_archive(&self, request_id: ConnectionRequestId, params: ThreadArchiveParams) { - let thread_id = match ThreadId::from_string(¶ms.thread_id) { - Ok(id) => id, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid thread id: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; + let result = self.thread_archive_response(params).await; + let archived_thread_ids = result + .as_ref() + .ok() + .map(|(_, thread_ids)| thread_ids.clone()); + self.outgoing + .send_result(request_id, result.map(|(response, _)| response)) + .await; + + if let Some(archived_thread_ids) = archived_thread_ids { + for thread_id in archived_thread_ids { + let notification = ThreadArchivedNotification { thread_id }; + self.outgoing + .send_server_notification(ServerNotification::ThreadArchived(notification)) + .await; } - }; + } + } + + async fn thread_archive_response( + &self, + params: ThreadArchiveParams, + ) -> Result<(ThreadArchiveResponse, Vec), JSONRPCErrorError> { + let thread_id = ThreadId::from_string(¶ms.thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; let mut thread_ids = vec![thread_id]; if let Some(state_db_ctx) = get_state_db(&self.config).await { - let descendants = match state_db_ctx.list_thread_spawn_descendants(thread_id).await { - Ok(descendants) => descendants, - Err(err) => { - self.outgoing - .send_error( - request_id, - JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!( - "failed to list spawned descendants for thread id {thread_id}: {err}" - ), - data: None, - }, - ) - .await; - return; - } - }; + let descendants = state_db_ctx + .list_thread_spawn_descendants(thread_id) + .await + .map_err(|err| { + internal_error(format!( + "failed to list spawned descendants for thread id {thread_id}: {err}" + )) + })?; let mut seen = HashSet::from([thread_id]); for descendant_id in descendants { if seen.insert(descendant_id) { @@ -2826,12 +2866,7 @@ impl CodexMessageProcessor { archive_thread_ids.push(thread_id); } } - Err(err) => { - self.outgoing - .send_error(request_id, thread_store_archive_error("archive", err)) - .await; - return; - } + Err(err) => return Err(thread_store_archive_error("archive", err)), } for descendant_thread_id in thread_ids.into_iter().skip(1) { match self @@ -2859,10 +2894,7 @@ impl CodexMessageProcessor { let mut archived_thread_ids = Vec::new(); let Some((parent_thread_id, descendant_thread_ids)) = archive_thread_ids.split_first() else { - self.outgoing - .send_response(request_id, ThreadArchiveResponse {}) - .await; - return; + return Ok((ThreadArchiveResponse {}, archived_thread_ids)); }; self.prepare_thread_for_archive(*parent_thread_id).await; @@ -2876,12 +2908,7 @@ impl CodexMessageProcessor { Ok(()) => { archived_thread_ids.push(parent_thread_id.to_string()); } - Err(err) => { - self.outgoing - .send_error(request_id, thread_store_archive_error("archive", err)) - .await; - return; - } + Err(err) => return Err(thread_store_archive_error("archive", err)), } for descendant_thread_id in descendant_thread_ids.iter().rev().copied() { @@ -2904,15 +2931,7 @@ impl CodexMessageProcessor { } } - self.outgoing - .send_response(request_id, ThreadArchiveResponse {}) - .await; - for thread_id in archived_thread_ids { - let notification = ThreadArchivedNotification { thread_id }; - self.outgoing - .send_server_notification(ServerNotification::ThreadArchived(notification)) - .await; - } + Ok((ThreadArchiveResponse {}, archived_thread_ids)) } async fn thread_increment_elicitation( @@ -2920,34 +2939,23 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: ThreadIncrementElicitationParams, ) { - let (_, thread) = match self.load_thread(¶ms.thread_id).await { - Ok(value) => value, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; - - match thread.increment_out_of_band_elicitation_count().await { - Ok(count) => { - self.outgoing - .send_response( - request_id, - ThreadIncrementElicitationResponse { - count, - paused: count > 0, - }, - ) - .await; - } - Err(err) => { - self.send_internal_error( - request_id, - format!("failed to increment out-of-band elicitation counter: {err}"), - ) - .await; - } + let result = async { + let (_, thread) = self.load_thread(¶ms.thread_id).await?; + let count = thread + .increment_out_of_band_elicitation_count() + .await + .map_err(|err| { + internal_error(format!( + "failed to increment out-of-band elicitation counter: {err}" + )) + })?; + Ok::<_, JSONRPCErrorError>(ThreadIncrementElicitationResponse { + count, + paused: count > 0, + }) } + .await; + self.outgoing.send_result(request_id, result).await; } async fn thread_decrement_elicitation( @@ -2955,76 +2963,64 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: ThreadDecrementElicitationParams, ) { - let (_, thread) = match self.load_thread(¶ms.thread_id).await { - Ok(value) => value, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; - - match thread.decrement_out_of_band_elicitation_count().await { - Ok(count) => { - self.outgoing - .send_response( - request_id, - ThreadDecrementElicitationResponse { - count, - paused: count > 0, - }, - ) - .await; - } - Err(CodexErr::InvalidRequest(message)) => { - self.send_invalid_request_error(request_id, message).await; - } - Err(err) => { - self.send_internal_error( - request_id, - format!("failed to decrement out-of-band elicitation counter: {err}"), - ) - .await; - } + let result = async { + let (_, thread) = self.load_thread(¶ms.thread_id).await?; + let count = thread + .decrement_out_of_band_elicitation_count() + .await + .map_err(|err| match err { + CodexErr::InvalidRequest(message) => invalid_request(message), + err => internal_error(format!( + "failed to decrement out-of-band elicitation counter: {err}" + )), + })?; + Ok::<_, JSONRPCErrorError>(ThreadDecrementElicitationResponse { + count, + paused: count > 0, + }) } + .await; + self.outgoing.send_result(request_id, result).await; } async fn thread_set_name(&self, request_id: ConnectionRequestId, params: ThreadSetNameParams) { - let ThreadSetNameParams { thread_id, name } = params; - let thread_id = match ThreadId::from_string(&thread_id) { - Ok(id) => id, - Err(err) => { - self.send_invalid_request_error(request_id, format!("invalid thread id: {err}")) - .await; - return; - } - }; - let Some(name) = codex_core::util::normalize_thread_name(&name) else { - self.send_invalid_request_error( - request_id, - "thread name must not be empty".to_string(), - ) + let result = self.thread_set_name_response(&request_id, params).await; + let notification = result + .as_ref() + .ok() + .and_then(|(_, notification)| notification.clone()); + self.outgoing + .send_result(request_id, result.map(|(response, _)| response)) .await; - return; + + if let Some(notification) = notification { + self.outgoing + .send_server_notification(ServerNotification::ThreadNameUpdated(notification)) + .await; + } + } + + async fn thread_set_name_response( + &self, + request_id: &ConnectionRequestId, + params: ThreadSetNameParams, + ) -> Result<(ThreadSetNameResponse, Option), JSONRPCErrorError> + { + let ThreadSetNameParams { thread_id, name } = params; + let thread_id = ThreadId::from_string(&thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + let Some(name) = codex_core::util::normalize_thread_name(&name) else { + return Err(invalid_request("thread name must not be empty")); }; if let Ok(thread) = self.thread_manager.get_thread(thread_id).await { - if let Err(err) = self - .submit_core_op(&request_id, thread.as_ref(), Op::SetThreadName { name }) + self.submit_core_op(request_id, thread.as_ref(), Op::SetThreadName { name }) .await - { - self.send_internal_error(request_id, format!("failed to set thread name: {err}")) - .await; - return; - } - - self.outgoing - .send_response(request_id, ThreadSetNameResponse {}) - .await; - return; + .map_err(|err| internal_error(format!("failed to set thread name: {err}")))?; + return Ok((ThreadSetNameResponse {}, None)); } - if let Err(err) = self - .thread_store + self.thread_store .update_thread_metadata(StoreUpdateThreadMetadataParams { thread_id, patch: StoreThreadMetadataPatch { @@ -3034,23 +3030,15 @@ impl CodexMessageProcessor { include_archived: false, }) .await - { - self.outgoing - .send_error(request_id, thread_store_write_error("set thread name", err)) - .await; - return; - } + .map_err(|err| thread_store_write_error("set thread name", err))?; - self.outgoing - .send_response(request_id, ThreadSetNameResponse {}) - .await; - let notification = ThreadNameUpdatedNotification { - thread_id: thread_id.to_string(), - thread_name: Some(name), - }; - self.outgoing - .send_server_notification(ServerNotification::ThreadNameUpdated(notification)) - .await; + Ok(( + ThreadSetNameResponse {}, + Some(ThreadNameUpdatedNotification { + thread_id: thread_id.to_string(), + thread_name: Some(name), + }), + )) } async fn thread_memory_mode_set( @@ -3058,43 +3046,35 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: ThreadMemoryModeSetParams, ) { + let result = self.thread_memory_mode_set_response(params).await; + self.outgoing.send_result(request_id, result).await; + } + + async fn thread_memory_mode_set_response( + &self, + params: ThreadMemoryModeSetParams, + ) -> Result { let ThreadMemoryModeSetParams { thread_id, mode } = params; - let thread_id = match ThreadId::from_string(&thread_id) { - Ok(id) => id, - Err(err) => { - self.send_invalid_request_error(request_id, format!("invalid thread id: {err}")) - .await; - return; - } - }; + let thread_id = ThreadId::from_string(&thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; if let Ok(thread) = self.thread_manager.get_thread(thread_id).await { if thread.config_snapshot().await.ephemeral { - self.send_invalid_request_error( - request_id, - format!("ephemeral thread does not support memory mode updates: {thread_id}"), - ) - .await; - return; + return Err(invalid_request(format!( + "ephemeral thread does not support memory mode updates: {thread_id}" + ))); } - if let Err(err) = thread.set_thread_memory_mode(mode.to_core()).await { - self.send_internal_error( - request_id, - format!("failed to set thread memory mode: {err}"), - ) - .await; - return; - } - - self.outgoing - .send_response(request_id, ThreadMemoryModeSetResponse {}) - .await; - return; + thread + .set_thread_memory_mode(mode.to_core()) + .await + .map_err(|err| { + internal_error(format!("failed to set thread memory mode: {err}")) + })?; + return Ok(ThreadMemoryModeSetResponse {}); } - if let Err(err) = self - .thread_store + self.thread_store .update_thread_metadata(StoreUpdateThreadMetadataParams { thread_id, patch: StoreThreadMetadataPatch { @@ -3104,63 +3084,40 @@ impl CodexMessageProcessor { include_archived: false, }) .await - { - self.outgoing - .send_error( - request_id, - thread_store_write_error("set thread memory mode", err), - ) - .await; - return; - } + .map_err(|err| thread_store_write_error("set thread memory mode", err))?; - self.outgoing - .send_response(request_id, ThreadMemoryModeSetResponse {}) - .await; + Ok(ThreadMemoryModeSetResponse {}) } async fn memory_reset(&self, request_id: ConnectionRequestId, _params: Option<()>) { - let state_db = match StateRuntime::init( + let result = self.memory_reset_response().await; + self.outgoing.send_result(request_id, result).await; + } + + async fn memory_reset_response(&self) -> Result { + let state_db = StateRuntime::init( self.config.sqlite_home.clone(), self.config.model_provider_id.clone(), ) .await - { - Ok(state_db) => state_db, - Err(err) => { - self.send_internal_error( - request_id, - format!("failed to open state db for memory reset: {err}"), - ) - .await; - return; - } - }; + .map_err(|err| { + internal_error(format!("failed to open state db for memory reset: {err}")) + })?; - if let Err(err) = state_db.clear_memory_data().await { - self.send_internal_error( - request_id, - format!("failed to clear memory rows in state db: {err}"), - ) - .await; - return; - } + state_db.clear_memory_data().await.map_err(|err| { + internal_error(format!("failed to clear memory rows in state db: {err}")) + })?; - if let Err(err) = clear_memory_roots_contents(&self.config.codex_home).await { - self.send_internal_error( - request_id, - format!( + clear_memory_roots_contents(&self.config.codex_home) + .await + .map_err(|err| { + internal_error(format!( "failed to clear memory directories under {}: {err}", self.config.codex_home.display() - ), - ) - .await; - return; - } + )) + })?; - self.outgoing - .send_response(request_id, MemoryResetResponse {}) - .await; + Ok(MemoryResetResponse {}) } async fn thread_metadata_update( @@ -3168,19 +3125,21 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: ThreadMetadataUpdateParams, ) { + let result = self.thread_metadata_update_response(params).await; + self.outgoing.send_result(request_id, result).await; + } + + async fn thread_metadata_update_response( + &self, + params: ThreadMetadataUpdateParams, + ) -> Result { let ThreadMetadataUpdateParams { thread_id, git_info, } = params; - let thread_uuid = match ThreadId::from_string(&thread_id) { - Ok(id) => id, - Err(err) => { - self.send_invalid_request_error(request_id, format!("invalid thread id: {err}")) - .await; - return; - } - }; + let thread_uuid = ThreadId::from_string(&thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; let Some(ThreadMetadataGitInfoUpdateParams { sha, @@ -3188,21 +3147,11 @@ impl CodexMessageProcessor { origin_url, }) = git_info else { - self.send_invalid_request_error( - request_id, - "gitInfo must include at least one field".to_string(), - ) - .await; - return; + return Err(invalid_request("gitInfo must include at least one field")); }; if sha.is_none() && branch.is_none() && origin_url.is_none() { - self.send_invalid_request_error( - request_id, - "gitInfo must include at least one field".to_string(), - ) - .await; - return; + return Err(invalid_request("gitInfo must include at least one field")); } let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok(); @@ -3211,72 +3160,20 @@ impl CodexMessageProcessor { state_db_ctx = get_state_db(&self.config).await; } let Some(state_db_ctx) = state_db_ctx else { - self.send_internal_error( - request_id, - format!("sqlite state db unavailable for thread {thread_uuid}"), - ) - .await; - return; + return Err(internal_error(format!( + "sqlite state db unavailable for thread {thread_uuid}" + ))); }; - if let Err(error) = self - .ensure_thread_metadata_row_exists(thread_uuid, &state_db_ctx, loaded_thread.as_ref()) - .await - { - self.outgoing.send_error(request_id, error).await; - return; - } + self.ensure_thread_metadata_row_exists(thread_uuid, &state_db_ctx, loaded_thread.as_ref()) + .await?; - let git_sha = match sha { - Some(Some(sha)) => { - let sha = sha.trim().to_string(); - if sha.is_empty() { - self.send_invalid_request_error( - request_id, - "gitInfo.sha must not be empty".to_string(), - ) - .await; - return; - } - Some(Some(sha)) - } - Some(None) => Some(None), - None => None, - }; - let git_branch = match branch { - Some(Some(branch)) => { - let branch = branch.trim().to_string(); - if branch.is_empty() { - self.send_invalid_request_error( - request_id, - "gitInfo.branch must not be empty".to_string(), - ) - .await; - return; - } - Some(Some(branch)) - } - Some(None) => Some(None), - None => None, - }; - let git_origin_url = match origin_url { - Some(Some(origin_url)) => { - let origin_url = origin_url.trim().to_string(); - if origin_url.is_empty() { - self.send_invalid_request_error( - request_id, - "gitInfo.originUrl must not be empty".to_string(), - ) - .await; - return; - } - Some(Some(origin_url)) - } - Some(None) => Some(None), - None => None, - }; + let git_sha = Self::normalize_thread_metadata_git_field(sha, "gitInfo.sha")?; + let git_branch = Self::normalize_thread_metadata_git_field(branch, "gitInfo.branch")?; + let git_origin_url = + Self::normalize_thread_metadata_git_field(origin_url, "gitInfo.originUrl")?; - let updated = match state_db_ctx + let updated = state_db_ctx .update_thread_git_info( thread_uuid, git_sha.as_ref().map(|value| value.as_deref()), @@ -3284,35 +3181,23 @@ impl CodexMessageProcessor { git_origin_url.as_ref().map(|value| value.as_deref()), ) .await - { - Ok(updated) => updated, - Err(err) => { - self.send_internal_error( - request_id, - format!("failed to update thread metadata for {thread_uuid}: {err}"), - ) - .await; - return; - } - }; + .map_err(|err| { + internal_error(format!( + "failed to update thread metadata for {thread_uuid}: {err}" + )) + })?; if !updated { - self.send_internal_error( - request_id, - format!("thread metadata disappeared before update completed: {thread_uuid}"), - ) - .await; - return; + return Err(internal_error(format!( + "thread metadata disappeared before update completed: {thread_uuid}" + ))); } let Some(summary) = read_summary_from_state_db_context_by_thread_id(Some(&state_db_ctx), thread_uuid).await else { - self.send_internal_error( - request_id, - format!("failed to reload updated thread metadata for {thread_uuid}"), - ) - .await; - return; + return Err(internal_error(format!( + "failed to reload updated thread metadata for {thread_uuid}" + ))); }; let mut thread = summary_to_thread(summary, &self.config.cwd); @@ -3324,9 +3209,24 @@ impl CodexMessageProcessor { /*has_in_progress_turn*/ false, ); - self.outgoing - .send_response(request_id, ThreadMetadataUpdateResponse { thread }) - .await; + Ok(ThreadMetadataUpdateResponse { thread }) + } + + fn normalize_thread_metadata_git_field( + value: Option>, + name: &str, + ) -> Result>, JSONRPCErrorError> { + match value { + Some(Some(value)) => { + let value = value.trim().to_string(); + if value.is_empty() { + return Err(invalid_request(format!("{name} must not be empty"))); + } + Ok(Some(Some(value))) + } + Some(None) => Ok(Some(None)), + None => Ok(None), + } } async fn ensure_thread_metadata_row_exists( @@ -3335,22 +3235,6 @@ impl CodexMessageProcessor { state_db_ctx: &Arc, loaded_thread: Option<&Arc>, ) -> Result<(), JSONRPCErrorError> { - fn invalid_request(message: String) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message, - data: None, - } - } - - fn internal_error(message: String) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message, - data: None, - } - } - match state_db_ctx.get_thread(thread_uuid).await { Ok(Some(_)) => return Ok(()), Ok(None) => {} @@ -3466,21 +3350,34 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: ThreadUnarchiveParams, ) { - let thread_id = match ThreadId::from_string(¶ms.thread_id) { - Ok(id) => id, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid thread id: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - }; + let result = self.thread_unarchive_response(params).await; + let notification = + result + .as_ref() + .ok() + .map(|(_, thread_id)| ThreadUnarchivedNotification { + thread_id: thread_id.clone(), + }); + self.outgoing + .send_result(request_id, result.map(|(response, _)| response)) + .await; + + if let Some(notification) = notification { + self.outgoing + .send_server_notification(ServerNotification::ThreadUnarchived(notification)) + .await; + } + } + + async fn thread_unarchive_response( + &self, + params: ThreadUnarchiveParams, + ) -> Result<(ThreadUnarchiveResponse, String), JSONRPCErrorError> { + let thread_id = ThreadId::from_string(¶ms.thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; let fallback_provider = self.config.model_provider_id.clone(); - let result = self + let mut thread = self .thread_store .unarchive_thread(StoreArchiveThreadParams { thread_id }) .await @@ -3493,50 +3390,42 @@ impl CodexMessageProcessor { message: format!("failed to read unarchived thread {thread_id}"), data: None, }) - }); + })?; - match result { - Ok(mut thread) => { - thread.status = resolve_thread_status( - self.thread_watch_manager - .loaded_status_for_thread(&thread.id) - .await, - /*has_in_progress_turn*/ false, - ); - self.attach_thread_name(thread_id, &mut thread).await; - let thread_id = thread.id.clone(); - let response = ThreadUnarchiveResponse { thread }; - self.outgoing.send_response(request_id, response).await; - let notification = ThreadUnarchivedNotification { thread_id }; - self.outgoing - .send_server_notification(ServerNotification::ThreadUnarchived(notification)) - .await; - } - Err(err) => { - self.outgoing.send_error(request_id, err).await; - } - } + thread.status = resolve_thread_status( + self.thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await, + /*has_in_progress_turn*/ false, + ); + self.attach_thread_name(thread_id, &mut thread).await; + let thread_id = thread.id.clone(); + Ok((ThreadUnarchiveResponse { thread }, thread_id)) } async fn thread_rollback(&self, request_id: ConnectionRequestId, params: ThreadRollbackParams) { + let result = self + .thread_rollback_start(&request_id, params) + .await + .map(|()| None::); + self.send_optional_result(request_id, result).await; + } + + async fn thread_rollback_start( + &self, + request_id: &ConnectionRequestId, + params: ThreadRollbackParams, + ) -> Result<(), JSONRPCErrorError> { let ThreadRollbackParams { thread_id, num_turns, } = params; if num_turns == 0 { - self.send_invalid_request_error(request_id, "numTurns must be >= 1".to_string()) - .await; - return; + return Err(invalid_request("numTurns must be >= 1")); } - let (thread_id, thread) = match self.load_thread(&thread_id).await { - Ok(v) => v, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; + let (thread_id, thread) = self.load_thread(&thread_id).await?; let request = request_id.clone(); @@ -3551,17 +3440,14 @@ impl CodexMessageProcessor { } }; if rollback_already_in_progress { - self.send_invalid_request_error( - request.clone(), - "rollback already in progress for this thread".to_string(), - ) - .await; - return; + return Err(invalid_request( + "rollback already in progress for this thread", + )); } if let Err(err) = self .submit_core_op( - &request_id, + request_id, thread.as_ref(), Op::ThreadRollback { num_turns }, ) @@ -3572,9 +3458,9 @@ impl CodexMessageProcessor { let thread_state = self.thread_state_manager.thread_state(thread_id).await; thread_state.lock().await.pending_rollbacks = None; - self.send_internal_error(request, format!("failed to start rollback: {err}")) - .await; + return Err(internal_error(format!("failed to start rollback: {err}"))); } + Ok(()) } async fn thread_compact_start( @@ -3584,28 +3470,15 @@ impl CodexMessageProcessor { ) { let ThreadCompactStartParams { thread_id } = params; - let (_, thread) = match self.load_thread(&thread_id).await { - Ok(v) => v, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; - - match self - .submit_core_op(&request_id, thread.as_ref(), Op::Compact) - .await - { - Ok(_) => { - self.outgoing - .send_response(request_id, ThreadCompactStartResponse {}) - .await; - } - Err(err) => { - self.send_internal_error(request_id, format!("failed to start compaction: {err}")) - .await; - } + let result = async { + let (_, thread) = self.load_thread(&thread_id).await?; + self.submit_core_op(&request_id, thread.as_ref(), Op::Compact) + .await + .map_err(|err| internal_error(format!("failed to start compaction: {err}")))?; + Ok::<_, JSONRPCErrorError>(ThreadCompactStartResponse {}) } + .await; + self.outgoing.send_result(request_id, result).await; } async fn thread_background_terminals_clean( @@ -3615,31 +3488,17 @@ impl CodexMessageProcessor { ) { let ThreadBackgroundTerminalsCleanParams { thread_id } = params; - let (_, thread) = match self.load_thread(&thread_id).await { - Ok(v) => v, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; - - match self - .submit_core_op(&request_id, thread.as_ref(), Op::CleanBackgroundTerminals) - .await - { - Ok(_) => { - self.outgoing - .send_response(request_id, ThreadBackgroundTerminalsCleanResponse {}) - .await; - } - Err(err) => { - self.send_internal_error( - request_id, - format!("failed to clean background terminals: {err}"), - ) - .await; - } + let result = async { + let (_, thread) = self.load_thread(&thread_id).await?; + self.submit_core_op(&request_id, thread.as_ref(), Op::CleanBackgroundTerminals) + .await + .map_err(|err| { + internal_error(format!("failed to clean background terminals: {err}")) + })?; + Ok::<_, JSONRPCErrorError>(ThreadBackgroundTerminalsCleanResponse {}) } + .await; + self.outgoing.send_result(request_id, result).await; } async fn thread_shell_command( @@ -3647,54 +3506,60 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: ThreadShellCommandParams, ) { - let ThreadShellCommandParams { thread_id, command } = params; - let command = command.trim().to_string(); - if command.is_empty() { - self.outgoing - .send_error( - request_id, - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "command must not be empty".to_string(), - data: None, - }, - ) - .await; - return; - } - - let (_, thread) = match self.load_thread(&thread_id).await { - Ok(v) => v, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; + let result = async { + let ThreadShellCommandParams { thread_id, command } = params; + let command = command.trim().to_string(); + if command.is_empty() { + return Err(invalid_request("command must not be empty")); } - }; - match self - .submit_core_op( + let (_, thread) = self.load_thread(&thread_id).await?; + self.submit_core_op( &request_id, thread.as_ref(), Op::RunUserShellCommand { command }, ) .await - { - Ok(_) => { - self.outgoing - .send_response(request_id, ThreadShellCommandResponse {}) - .await; - } - Err(err) => { - self.send_internal_error( - request_id, - format!("failed to start shell command: {err}"), - ) - .await; - } + .map_err(|err| internal_error(format!("failed to start shell command: {err}")))?; + Ok::<_, JSONRPCErrorError>(ThreadShellCommandResponse {}) } + .await; + self.outgoing.send_result(request_id, result).await; + } + + async fn thread_approve_guardian_denied_action( + &self, + request_id: ConnectionRequestId, + params: ThreadApproveGuardianDeniedActionParams, + ) { + let result = async { + let ThreadApproveGuardianDeniedActionParams { thread_id, event } = params; + let event = serde_json::from_value(event) + .map_err(|err| invalid_request(format!("invalid Guardian denial event: {err}")))?; + let (_, thread) = self.load_thread(&thread_id).await?; + + self.submit_core_op( + &request_id, + thread.as_ref(), + Op::ApproveGuardianDeniedAction { event }, + ) + .await + .map_err(|err| internal_error(format!("failed to approve Guardian denial: {err}")))?; + Ok::<_, JSONRPCErrorError>(ThreadApproveGuardianDeniedActionResponse {}) + } + .await; + self.outgoing.send_result(request_id, result).await; } async fn thread_list(&self, request_id: ConnectionRequestId, params: ThreadListParams) { + let result = self.thread_list_response(params).await; + self.outgoing.send_result(request_id, result).await; + } + + async fn thread_list_response( + &self, + params: ThreadListParams, + ) -> Result { let ThreadListParams { cursor, limit, @@ -3704,15 +3569,10 @@ impl CodexMessageProcessor { source_kinds, archived, cwd, + use_state_db_only, search_term, } = params; - let cwd = match normalize_thread_list_cwd_filter(cwd) { - Ok(cwd) => cwd, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; + let cwd_filters = normalize_thread_list_cwd_filters(cwd)?; let requested_page_size = limit .map(|value| value as usize) @@ -3723,7 +3583,7 @@ impl CodexMessageProcessor { ThreadSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt, }; let sort_direction = sort_direction.unwrap_or(SortDirection::Desc); - let list_result = self + let (summaries, next_cursor) = self .list_threads_common( requested_page_size, cursor, @@ -3733,18 +3593,12 @@ impl CodexMessageProcessor { model_providers, source_kinds, archived: archived.unwrap_or(false), - cwd, + cwd_filters, search_term, + use_state_db_only, }, ) - .await; - let (summaries, next_cursor) = match list_result { - Ok(r) => r, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; + .await?; let backwards_cursor = summaries.first().and_then(|summary| { thread_backwards_cursor_for_sort_key(summary, store_sort_key, sort_direction) }); @@ -3780,12 +3634,11 @@ impl CodexMessageProcessor { thread }) .collect(); - let response = ThreadListResponse { + Ok(ThreadListResponse { data, next_cursor, backwards_cursor, - }; - self.outgoing.send_response(request_id, response).await; + }) } async fn thread_loaded_list( @@ -3793,6 +3646,14 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: ThreadLoadedListParams, ) { + let result = self.thread_loaded_list_response(params).await; + self.outgoing.send_result(request_id, result).await; + } + + async fn thread_loaded_list_response( + &self, + params: ThreadLoadedListParams, + ) -> Result { let ThreadLoadedListParams { cursor, limit } = params; let mut data = self .thread_manager @@ -3803,12 +3664,10 @@ impl CodexMessageProcessor { .collect::>(); if data.is_empty() { - let response = ThreadLoadedListResponse { + return Ok(ThreadLoadedListResponse { data, next_cursor: None, - }; - self.outgoing.send_response(request_id, response).await; - return; + }); } data.sort(); @@ -3817,15 +3676,7 @@ impl CodexMessageProcessor { Some(cursor) => { let cursor = match ThreadId::from_string(&cursor) { Ok(id) => id.to_string(), - Err(_) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid cursor: {cursor}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } + Err(_) => return Err(invalid_request(format!("invalid cursor: {cursor}"))), }; match data.binary_search(&cursor) { Ok(idx) => idx + 1, @@ -3840,41 +3691,34 @@ impl CodexMessageProcessor { let page = data[start..end].to_vec(); let next_cursor = page.last().filter(|_| end < total).cloned(); - let response = ThreadLoadedListResponse { + Ok(ThreadLoadedListResponse { data: page, next_cursor, - }; - self.outgoing.send_response(request_id, response).await; + }) } async fn thread_read(&self, request_id: ConnectionRequestId, params: ThreadReadParams) { + let result = self.thread_read_response(params).await; + self.outgoing.send_result(request_id, result).await; + } + + async fn thread_read_response( + &self, + params: ThreadReadParams, + ) -> Result { let ThreadReadParams { thread_id, include_turns, } = params; - let thread_uuid = match ThreadId::from_string(&thread_id) { - Ok(id) => id, - Err(err) => { - self.send_invalid_request_error(request_id, format!("invalid thread id: {err}")) - .await; - return; - } - }; + let thread_uuid = ThreadId::from_string(&thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; - let thread = match self.read_thread_view(thread_uuid, include_turns).await { - Ok(thread) => thread, - Err(ThreadReadViewError::InvalidRequest(message)) => { - self.send_invalid_request_error(request_id, message).await; - return; - } - Err(ThreadReadViewError::Internal(message)) => { - self.send_internal_error(request_id, message).await; - return; - } - }; - let response = ThreadReadResponse { thread }; - self.outgoing.send_response(request_id, response).await; + let thread = self + .read_thread_view(thread_uuid, include_turns) + .await + .map_err(thread_read_view_error)?; + Ok(ThreadReadResponse { thread }) } /// Builds the API view for `thread/read` from persisted metadata plus optional live state. @@ -4032,6 +3876,14 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: ThreadTurnsListParams, ) { + let result = self.thread_turns_list_response(params).await; + self.outgoing.send_result(request_id, result).await; + } + + async fn thread_turns_list_response( + &self, + params: ThreadTurnsListParams, + ) -> Result { let ThreadTurnsListParams { thread_id, cursor, @@ -4039,14 +3891,8 @@ impl CodexMessageProcessor { sort_direction, } = params; - let thread_uuid = match ThreadId::from_string(&thread_id) { - Ok(id) => id, - Err(err) => { - self.send_invalid_request_error(request_id, format!("invalid thread id: {err}")) - .await; - return; - } - }; + let thread_uuid = ThreadId::from_string(&thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; let state_db_ctx = get_state_db(&self.config).await; let mut rollout_path = self @@ -4066,21 +3912,15 @@ impl CodexMessageProcessor { { Ok(path) => path, Err(err) => { - self.send_invalid_request_error( - request_id, - format!("failed to locate archived thread id {thread_uuid}: {err}"), - ) - .await; - return; + return Err(invalid_request(format!( + "failed to locate archived thread id {thread_uuid}: {err}" + ))); } }, Err(err) => { - self.send_invalid_request_error( - request_id, - format!("failed to locate thread id {thread_uuid}: {err}"), - ) - .await; - return; + return Err(invalid_request(format!( + "failed to locate thread id {thread_uuid}: {err}" + ))); } }; } @@ -4090,92 +3930,63 @@ impl CodexMessageProcessor { Ok(thread) => { rollout_path = thread.rollout_path(); if rollout_path.is_none() { - self.send_invalid_request_error( - request_id, - "ephemeral threads do not support thread/turns/list".to_string(), - ) - .await; - return; + return Err(invalid_request( + "ephemeral threads do not support thread/turns/list", + )); } } - Err(_) => { - self.send_invalid_request_error( - request_id, - format!("thread not loaded: {thread_uuid}"), - ) - .await; - return; - } + Err(_) => return Err(invalid_request(format!("thread not loaded: {thread_uuid}"))), } } let Some(rollout_path) = rollout_path.as_ref() else { - self.send_internal_error( - request_id, - format!("failed to locate rollout for thread {thread_uuid}"), - ) - .await; - return; + return Err(internal_error(format!( + "failed to locate rollout for thread {thread_uuid}" + ))); }; - match read_rollout_items_from_rollout(rollout_path).await { - Ok(items) => { - // This API optimizes network transfer by letting clients page through a - // thread's turns incrementally, but it still replays the entire rollout on - // every request. Rollback and compaction events can change earlier turns, so - // the server has to rebuild the full turn list until turn metadata is indexed - // separately. - let has_live_in_progress_turn = - match self.thread_manager.get_thread(thread_uuid).await { - Ok(thread) => matches!(thread.agent_status().await, AgentStatus::Running), - Err(_) => false, - }; - let turns = reconstruct_thread_turns_from_rollout_items( - &items, - self.thread_watch_manager - .loaded_status_for_thread(&thread_uuid.to_string()) - .await, - has_live_in_progress_turn, - ); - let page = match paginate_thread_turns( - turns, - cursor.as_deref(), - limit, - sort_direction.unwrap_or(SortDirection::Desc), - ) { - Ok(page) => page, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; - let response = ThreadTurnsListResponse { - data: page.turns, - next_cursor: page.next_cursor, - backwards_cursor: page.backwards_cursor, - }; - self.outgoing.send_response(request_id, response).await; - } + let items = match read_rollout_items_from_rollout(rollout_path).await { + Ok(items) => items, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - self.send_invalid_request_error( - request_id, - format!( - "thread {thread_uuid} is not materialized yet; thread/turns/list is unavailable before first user message" - ), - ) - .await; + return Err(invalid_request(format!( + "thread {thread_uuid} is not materialized yet; thread/turns/list is unavailable before first user message" + ))); } Err(err) => { - self.send_internal_error( - request_id, - format!( - "failed to load rollout `{}` for thread {thread_uuid}: {err}", - rollout_path.display() - ), - ) - .await; + return Err(internal_error(format!( + "failed to load rollout `{}` for thread {thread_uuid}: {err}", + rollout_path.display() + ))); } - } + }; + + // This API optimizes network transfer by letting clients page through a + // thread's turns incrementally, but it still replays the entire rollout on + // every request. Rollback and compaction events can change earlier turns, so + // the server has to rebuild the full turn list until turn metadata is indexed + // separately. + let has_live_in_progress_turn = match self.thread_manager.get_thread(thread_uuid).await { + Ok(thread) => matches!(thread.agent_status().await, AgentStatus::Running), + Err(_) => false, + }; + let turns = reconstruct_thread_turns_from_rollout_items( + &items, + self.thread_watch_manager + .loaded_status_for_thread(&thread_uuid.to_string()) + .await, + has_live_in_progress_turn, + ); + let page = paginate_thread_turns( + turns, + cursor.as_deref(), + limit, + sort_direction.unwrap_or(SortDirection::Desc), + )?; + Ok(ThreadTurnsListResponse { + data: page.turns, + next_cursor: page.next_cursor, + backwards_cursor: page.backwards_cursor, + }) } pub(crate) fn thread_created_receiver(&self) -> broadcast::Receiver { @@ -4247,23 +4058,36 @@ impl CodexMessageProcessor { .await .contains(&thread_id) { - self.send_invalid_request_error( - request_id, - format!( - "thread {thread_id} is closing; retry thread/resume after the thread is closed" - ), - ) - .await; + self.outgoing + .send_error( + request_id, + invalid_request(format!( + "thread {thread_id} is closing; retry thread/resume after the thread is closed" + )), + ) + .await; return; } - if self - .resume_running_thread(request_id.clone(), ¶ms) - .await - { + if params.sandbox.is_some() && params.permission_profile.is_some() { + self.outgoing + .send_error( + request_id, + invalid_request("`permissionProfile` cannot be combined with `sandbox`"), + ) + .await; return; } + match self.resume_running_thread(&request_id, ¶ms).await { + Ok(true) => return, + Ok(false) => {} + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + } + let ThreadResumeParams { thread_id, history, @@ -4275,29 +4099,30 @@ impl CodexMessageProcessor { approval_policy, approvals_reviewer, sandbox, + permission_profile, config: mut request_overrides, base_instructions, developer_instructions, personality, + exclude_turns, persist_extended_history, } = params; + let include_turns = !exclude_turns; - let thread_history = if let Some(history) = history { - let Some(thread_history) = self - .resume_thread_from_history(request_id.clone(), history.as_slice()) + let (thread_history, resume_source_thread) = match if let Some(history) = history { + self.resume_thread_from_history(history.as_slice()) .await - else { - return; - }; - thread_history + .map(|thread_history| (thread_history, None)) } else { - let Some(thread_history) = self - .resume_thread_from_rollout(request_id.clone(), &thread_id, path.as_ref()) + self.resume_thread_from_rollout(&thread_id, path.as_ref()) .await - else { + .map(|(thread_history, stored_thread)| (thread_history, Some(stored_thread))) + } { + Ok(value) => value, + Err(error) => { + self.outgoing.send_error(request_id, error).await; return; - }; - thread_history + } }; let history_cwd = thread_history.session_cwd(); @@ -4309,17 +4134,17 @@ impl CodexMessageProcessor { approval_policy, approvals_reviewer, sandbox, + permission_profile, base_instructions, developer_instructions, personality, ); - let persisted_resume_metadata = self - .load_and_apply_persisted_resume_metadata( - &thread_history, - &mut request_overrides, - &mut typesafe_overrides, - ) - .await; + self.load_and_apply_persisted_resume_metadata( + &thread_history, + &mut request_overrides, + &mut typesafe_overrides, + ) + .await; // Derive a Config using the same logic as new conversation, honoring overrides if provided. let config = match self @@ -4335,7 +4160,6 @@ impl CodexMessageProcessor { } }; - let fallback_model_provider = config.model_provider_id.clone(); let instruction_sources = Self::instruction_sources_from_config(&config).await; let response_history = thread_history.clone(); @@ -4358,11 +4182,9 @@ impl CodexMessageProcessor { }) => { let SessionConfiguredEvent { rollout_path, .. } = session_configured; let Some(rollout_path) = rollout_path else { - self.send_internal_error( - request_id, - format!("rollout path missing for thread {thread_id}"), - ) - .await; + let error = + internal_error(format!("rollout path missing for thread {thread_id}")); + self.outgoing.send_error(request_id, error).await; return; }; // Auto-attach a thread listener when resuming a thread. @@ -4385,14 +4207,16 @@ impl CodexMessageProcessor { codex_thread.as_ref(), &response_history, rollout_path.as_path(), - fallback_model_provider.as_str(), - persisted_resume_metadata.as_ref(), + resume_source_thread, + include_turns, ) .await { Ok(thread) => thread, Err(message) => { - self.send_internal_error(request_id, message).await; + self.outgoing + .send_error(request_id, internal_error(message)) + .await; return; } }; @@ -4411,6 +4235,9 @@ impl CodexMessageProcessor { thread_status, /*has_live_in_progress_turn*/ false, ); + let permission_profile = thread_response_permission_profile( + codex_thread.config_snapshot().await.permission_profile, + ); let response = ThreadResumeResponse { thread, @@ -4422,44 +4249,51 @@ impl CodexMessageProcessor { approval_policy: session_configured.approval_policy.into(), approvals_reviewer: session_configured.approvals_reviewer.into(), sandbox: session_configured.sandbox_policy.into(), + permission_profile, reasoning_effort: session_configured.reasoning_effort, }; - if self.config.features.enabled(Feature::GeneralAnalytics) { - self.analytics_events_client.track_response( - request_id.connection_id.0, - ClientResponse::ThreadResume { - request_id: request_id.request_id.clone(), - response: response.clone(), - }, - ); - } + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::ThreadResume { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); let connection_id = request_id.connection_id; - let token_usage_thread = response.thread.clone(); - let token_usage_turn_id = latest_token_usage_turn_id_from_rollout_items( - &response_history.get_rollout_items(), - &token_usage_thread, - ); + let token_usage_thread = include_turns.then(|| response.thread.clone()); self.outgoing.send_response(request_id, response).await; - // The client needs restored usage before it starts another turn. - // Sending after the response preserves JSON-RPC request ordering while - // still filling the status line before the next turn lifecycle begins. - send_thread_token_usage_update_to_connection( - &self.outgoing, - connection_id, - thread_id, - &token_usage_thread, - codex_thread.as_ref(), - token_usage_turn_id, - ) - .await; + // `excludeTurns` is explicitly the cheap resume path, so avoid + // rebuilding history only to attribute a replayed usage update. + if let Some(token_usage_thread) = token_usage_thread { + let token_usage_turn_id = latest_token_usage_turn_id_from_rollout_items( + &response_history.get_rollout_items(), + token_usage_thread.turns.as_slice(), + ); + // The client needs restored usage before it starts another turn. + // Sending after the response preserves JSON-RPC request ordering while + // still filling the status line before the next turn lifecycle begins. + send_thread_token_usage_update_to_connection( + &self.outgoing, + connection_id, + thread_id, + &token_usage_thread, + codex_thread.as_ref(), + token_usage_turn_id, + ) + .await; + } + if self.config.features.enabled(Feature::Goals) { + self.emit_thread_goal_snapshot(thread_id).await; + // App-server owns resume response and snapshot ordering, so wait + // until those are sent before letting core start goal continuation. + if let Err(err) = codex_thread.continue_active_goal_if_idle().await { + tracing::warn!("failed to continue active goal after resume: {err}"); + } + } } Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("error resuming thread: {err}"), - data: None, - }; + let error = internal_error(format!("error resuming thread: {err}")); self.outgoing.send_error(request_id, error).await; } } @@ -4486,110 +4320,64 @@ impl CodexMessageProcessor { async fn resume_running_thread( &self, - request_id: ConnectionRequestId, + request_id: &ConnectionRequestId, params: &ThreadResumeParams, - ) -> bool { + ) -> Result { if let Ok(existing_thread_id) = ThreadId::from_string(¶ms.thread_id) && let Ok(existing_thread) = self.thread_manager.get_thread(existing_thread_id).await { if params.history.is_some() { - self.send_invalid_request_error( - request_id, - format!( - "cannot resume thread {existing_thread_id} with history while it is already running" - ), - ) - .await; - return true; + return Err(invalid_request(format!( + "cannot resume thread {existing_thread_id} with history while it is already running" + ))); } - let rollout_path = if let Some(path) = existing_thread.rollout_path() { - if path.exists() { - path - } else { - match find_thread_path_by_id_str( - &self.config.codex_home, - &existing_thread_id.to_string(), - ) - .await - { - Ok(Some(path)) => path, - Ok(None) => { - self.send_invalid_request_error( - request_id, - format!("no rollout found for thread id {existing_thread_id}"), - ) - .await; - return true; - } - Err(err) => { - self.send_invalid_request_error( - request_id, - format!("failed to locate thread id {existing_thread_id}: {err}"), - ) - .await; - return true; - } - } - } - } else { - match find_thread_path_by_id_str( - &self.config.codex_home, - &existing_thread_id.to_string(), - ) - .await - { - Ok(Some(path)) => path, - Ok(None) => { - self.send_invalid_request_error( - request_id, - format!("no rollout found for thread id {existing_thread_id}"), - ) - .await; - return true; - } - Err(err) => { - self.send_invalid_request_error( - request_id, - format!("failed to locate thread id {existing_thread_id}: {err}"), - ) - .await; - return true; - } - } - }; - - if let Some(requested_path) = params.path.as_ref() - && requested_path != &rollout_path + if let (Some(requested_path), Some(active_path)) = ( + params.path.as_ref(), + existing_thread.rollout_path().as_ref(), + ) && requested_path != active_path { - self.send_invalid_request_error( - request_id, - format!( - "cannot resume running thread {existing_thread_id} with mismatched path: requested `{}`, active `{}`", - requested_path.display(), - rollout_path.display() - ), - ) - .await; - return true; + return Err(invalid_request(format!( + "cannot resume running thread {existing_thread_id} with mismatched path: requested `{}`, active `{}`", + requested_path.display(), + active_path.display() + ))); } + let source_thread = self + .read_stored_thread_for_resume( + ¶ms.thread_id, + params.path.as_ref(), + /*include_history*/ true, + ) + .await?; + if source_thread.thread_id != existing_thread_id { + return Err(invalid_request(format!( + "cannot resume running thread {existing_thread_id} from source thread {}", + source_thread.thread_id + ))); + } + let history_items = source_thread + .history + .as_ref() + .map(|history| history.items.clone()) + .ok_or_else(|| { + internal_error(format!( + "thread {existing_thread_id} did not include persisted history" + )) + })?; + let thread_state = self .thread_state_manager .thread_state(existing_thread_id) .await; - if let Err(error) = self - .ensure_listener_task_running( - existing_thread_id, - existing_thread.clone(), - thread_state.clone(), - ApiVersion::V2, - ) - .await - { - self.outgoing.send_error(request_id, error).await; - return true; - } + self.ensure_listener_task_running( + existing_thread_id, + existing_thread.clone(), + thread_state.clone(), + ApiVersion::V2, + ) + .await?; let config_snapshot = existing_thread.config_snapshot().await; let mismatch_details = collect_resume_override_mismatches(params, &config_snapshot); @@ -4600,77 +4388,75 @@ impl CodexMessageProcessor { mismatch_details.join("; ") ); } + let mut summary_source_thread = source_thread; + summary_source_thread.history = None; + let thread_summary = match self + .stored_thread_to_api_thread( + summary_source_thread, + config_snapshot.model_provider_id.as_str(), + /*include_turns*/ false, + ) + .await + { + Ok(thread) => thread, + Err(message) => return Err(internal_error(message)), + }; let mut config_for_instruction_sources = self.config.as_ref().clone(); config_for_instruction_sources.cwd = config_snapshot.cwd.clone(); let instruction_sources = Self::instruction_sources_from_config(&config_for_instruction_sources).await; - let thread_summary = match load_thread_summary_for_rollout( - &self.config, - existing_thread_id, - rollout_path.as_path(), - config_snapshot.model_provider_id.as_str(), - /*persisted_metadata*/ None, - ) - .await - { - Ok(thread) => thread, - Err(message) => { - self.send_internal_error(request_id, message).await; - return true; - } - }; let listener_command_tx = { let thread_state = thread_state.lock().await; thread_state.listener_command_tx() }; let Some(listener_command_tx) = listener_command_tx else { - let err = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!( - "failed to enqueue running thread resume for thread {existing_thread_id}: thread listener is not running" - ), - data: None, - }; - self.outgoing.send_error(request_id, err).await; - return true; + return Err(internal_error(format!( + "failed to enqueue running thread resume for thread {existing_thread_id}: thread listener is not running" + ))); + }; + + let emit_thread_goal_update = self.config.features.enabled(Feature::Goals); + let thread_goal_state_db = if emit_thread_goal_update { + if let Some(state_db) = existing_thread.state_db() { + Some(state_db) + } else { + open_state_db_for_direct_thread_lookup(&self.config).await + } + } else { + None }; let command = crate::thread_state::ThreadListenerCommand::SendThreadResumeResponse( Box::new(crate::thread_state::PendingThreadResumeRequest { request_id: request_id.clone(), - rollout_path: rollout_path.clone(), + history_items, config_snapshot, instruction_sources, thread_summary, + emit_thread_goal_update, + thread_goal_state_db, + include_turns: !params.exclude_turns, }), ); if listener_command_tx.send(command).is_err() { - let err = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!( - "failed to enqueue running thread resume for thread {existing_thread_id}: thread listener command channel is closed" - ), - data: None, - }; - self.outgoing.send_error(request_id, err).await; + return Err(internal_error(format!( + "failed to enqueue running thread resume for thread {existing_thread_id}: thread listener command channel is closed" + ))); } - return true; + return Ok(true); } - false + Ok(false) } async fn resume_thread_from_history( &self, - request_id: ConnectionRequestId, history: &[ResponseItem], - ) -> Option { + ) -> Result { if history.is_empty() { - self.send_invalid_request_error(request_id, "history must not be empty".to_string()) - .await; - return None; + return Err(invalid_request("history must not be empty")); } - Some(InitialHistory::Forked( + Ok(InitialHistory::Forked( history .iter() .cloned() @@ -4681,63 +4467,104 @@ impl CodexMessageProcessor { async fn resume_thread_from_rollout( &self, - request_id: ConnectionRequestId, thread_id: &str, path: Option<&PathBuf>, - ) -> Option { - let rollout_path = if let Some(path) = path { - path.clone() + ) -> Result<(InitialHistory, StoredThread), JSONRPCErrorError> { + let stored_thread = self + .read_stored_thread_for_resume(thread_id, path, /*include_history*/ true) + .await?; + let history = self + .stored_thread_to_initial_history(&stored_thread) + .await?; + Ok((history, stored_thread)) + } + + async fn read_stored_thread_for_resume( + &self, + thread_id: &str, + path: Option<&PathBuf>, + include_history: bool, + ) -> Result { + let result = if let Some(path) = path { + self.thread_store + .read_thread_by_rollout_path(StoreReadThreadByRolloutPathParams { + rollout_path: path.clone(), + include_archived: true, + include_history, + }) + .await } else { let existing_thread_id = match ThreadId::from_string(thread_id) { Ok(id) => id, Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid thread id: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return None; + return Err(invalid_request(format!("invalid thread id: {err}"))); } }; - - match find_thread_path_by_id_str( - &self.config.codex_home, - &existing_thread_id.to_string(), - ) - .await - { - Ok(Some(path)) => path, - Ok(None) => { - self.send_invalid_request_error( - request_id, - format!("no rollout found for thread id {existing_thread_id}"), - ) - .await; - return None; - } - Err(err) => { - self.send_invalid_request_error( - request_id, - format!("failed to locate thread id {existing_thread_id}: {err}"), - ) - .await; - return None; - } - } + let params = StoreReadThreadParams { + thread_id: existing_thread_id, + include_archived: true, + include_history, + }; + self.thread_store.read_thread(params).await }; - match RolloutRecorder::get_rollout_history(&rollout_path).await { - Ok(initial_history) => Some(initial_history), - Err(err) => { - self.send_invalid_request_error( - request_id, - format!("failed to load rollout `{}`: {err}", rollout_path.display()), - ) - .await; - None - } + result.map_err(thread_store_resume_read_error) + } + + async fn stored_thread_to_initial_history( + &self, + stored_thread: &StoredThread, + ) -> Result { + let thread_id = stored_thread.thread_id; + let history = stored_thread + .history + .as_ref() + .map(|history| history.items.clone()) + .ok_or_else(|| { + internal_error(format!( + "thread {thread_id} did not include persisted history" + )) + })?; + Ok(InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history, + rollout_path: stored_thread.rollout_path.clone(), + })) + } + + async fn stored_thread_to_api_thread( + &self, + stored_thread: StoredThread, + fallback_provider: &str, + include_turns: bool, + ) -> std::result::Result { + let (mut thread, history) = + thread_from_stored_thread(stored_thread, fallback_provider, &self.config.cwd); + if include_turns && let Some(history) = history { + populate_thread_turns( + &mut thread, + ThreadTurnSource::HistoryItems(&history.items), + /*active_turn*/ None, + ) + .await?; } + Ok(thread) + } + + async fn read_stored_thread_for_new_fork( + &self, + thread_store: &dyn ThreadStore, + thread_id: ThreadId, + include_history: bool, + ) -> Result { + thread_store + .read_thread(StoreReadThreadParams { + thread_id, + include_archived: true, + include_history, + }) + .await + .map_err(thread_store_resume_read_error) } async fn load_thread_from_resume_source_or_send_internal( @@ -4746,22 +4573,44 @@ impl CodexMessageProcessor { thread: &CodexThread, thread_history: &InitialHistory, rollout_path: &Path, - fallback_provider: &str, - persisted_resume_metadata: Option<&ThreadMetadata>, + resume_source_thread: Option, + include_turns: bool, ) -> std::result::Result { + let config_snapshot = thread.config_snapshot().await; let thread = match thread_history { InitialHistory::Resumed(resumed) => { - load_thread_summary_for_rollout( - &self.config, - resumed.conversation_id, - resumed.rollout_path.as_path(), - fallback_provider, - persisted_resume_metadata, - ) - .await + let fallback_provider = config_snapshot.model_provider_id.as_str(); + if let Some(mut stored_thread) = resume_source_thread { + stored_thread.history = None; + Ok(thread_from_stored_thread( + stored_thread, + fallback_provider, + &self.config.cwd, + ) + .0) + } else { + match self + .thread_store + .read_thread(StoreReadThreadParams { + thread_id: resumed.conversation_id, + include_archived: true, + include_history: false, + }) + .await + { + Ok(stored_thread) => Ok(thread_from_stored_thread( + stored_thread, + fallback_provider, + &self.config.cwd, + ) + .0), + Err(read_err) => { + Err(format!("failed to read thread from store: {read_err}")) + } + } + } } InitialHistory::Forked(items) => { - let config_snapshot = thread.config_snapshot().await; let mut thread = build_thread_from_snapshot( thread_id, &config_snapshot, @@ -4777,13 +4626,15 @@ impl CodexMessageProcessor { let mut thread = thread?; thread.id = thread_id.to_string(); thread.path = Some(rollout_path.to_path_buf()); - let history_items = thread_history.get_rollout_items(); - populate_thread_turns( - &mut thread, - ThreadTurnSource::HistoryItems(&history_items), - /*active_turn*/ None, - ) - .await?; + if include_turns { + let history_items = thread_history.get_rollout_items(); + populate_thread_turns( + &mut thread, + ThreadTurnSource::HistoryItems(&history_items), + /*active_turn*/ None, + ) + .await?; + } self.attach_thread_name(thread_id, &mut thread).await; Ok(thread) } @@ -4805,307 +4656,254 @@ impl CodexMessageProcessor { approval_policy, approvals_reviewer, sandbox, + permission_profile, config: cli_overrides, base_instructions, developer_instructions, ephemeral, + exclude_turns, persist_extended_history, } = params; + let include_turns = !exclude_turns; + let result = async { + if sandbox.is_some() && permission_profile.is_some() { + return Err(invalid_request( + "`permissionProfile` cannot be combined with `sandbox`", + )); + } - let (rollout_path, source_thread_id) = if let Some(path) = path { - (path, None) - } else { - let existing_thread_id = match ThreadId::from_string(&thread_id) { - Ok(id) => id, - Err(err) => { - self.send_invalid_request_error( - request_id, - format!("invalid thread id: {err}"), - ) - .await; - return; + let source_thread = self + .read_stored_thread_for_resume( + &thread_id, + path.as_ref(), + /*include_history*/ true, + ) + .await?; + let source_thread_id = source_thread.thread_id; + let history_items = source_thread + .history + .as_ref() + .map(|history| history.items.clone()) + .ok_or_else(|| { + internal_error(format!( + "thread {source_thread_id} did not include persisted history" + )) + })?; + let history_cwd = Some(source_thread.cwd.clone()); + + // Persist Windows sandbox mode. + let mut cli_overrides = cli_overrides.unwrap_or_default(); + if cfg!(windows) { + match WindowsSandboxLevel::from_config(&self.config) { + WindowsSandboxLevel::Elevated => { + cli_overrides + .insert("windows.sandbox".to_string(), serde_json::json!("elevated")); + } + WindowsSandboxLevel::RestrictedToken => { + cli_overrides.insert( + "windows.sandbox".to_string(), + serde_json::json!("unelevated"), + ); + } + WindowsSandboxLevel::Disabled => {} } + } + let request_overrides = if cli_overrides.is_empty() { + None + } else { + Some(cli_overrides) }; + let mut typesafe_overrides = self.build_thread_config_overrides( + model, + model_provider, + service_tier, + cwd, + approval_policy, + approvals_reviewer, + sandbox, + permission_profile, + base_instructions, + developer_instructions, + /*personality*/ None, + ); + typesafe_overrides.ephemeral = ephemeral.then_some(true); + // Derive a Config using the same logic as new conversation, honoring overrides if provided. + let config = self + .config_manager + .load_for_cwd(request_overrides, typesafe_overrides, history_cwd) + .await + .map_err(|err| config_load_error(&err))?; - match find_thread_path_by_id_str( - &self.config.codex_home, - &existing_thread_id.to_string(), - ) - .await - { - Ok(Some(p)) => (p, Some(existing_thread_id)), - Ok(None) => { - self.send_invalid_request_error( - request_id, - format!("no rollout found for thread id {existing_thread_id}"), - ) - .await; - return; - } - Err(err) => { - self.send_invalid_request_error( - request_id, - format!("failed to locate thread id {existing_thread_id}: {err}"), - ) - .await; - return; - } - } - }; + let fallback_model_provider = config.model_provider_id.clone(); + let instruction_sources = Self::instruction_sources_from_config(&config).await; + let fork_thread_store = configured_thread_store(&config); - let history_cwd = - read_history_cwd_from_state_db(&self.config, source_thread_id, rollout_path.as_path()) - .await; - - // Persist Windows sandbox mode. - let mut cli_overrides = cli_overrides.unwrap_or_default(); - if cfg!(windows) { - match WindowsSandboxLevel::from_config(&self.config) { - WindowsSandboxLevel::Elevated => { - cli_overrides - .insert("windows.sandbox".to_string(), serde_json::json!("elevated")); - } - WindowsSandboxLevel::RestrictedToken => { - cli_overrides.insert( - "windows.sandbox".to_string(), - serde_json::json!("unelevated"), - ); - } - WindowsSandboxLevel::Disabled => {} - } - } - let request_overrides = if cli_overrides.is_empty() { - None - } else { - Some(cli_overrides) - }; - let mut typesafe_overrides = self.build_thread_config_overrides( - model, - model_provider, - service_tier, - cwd, - approval_policy, - approvals_reviewer, - sandbox, - base_instructions, - developer_instructions, - /*personality*/ None, - ); - typesafe_overrides.ephemeral = ephemeral.then_some(true); - // Derive a Config using the same logic as new conversation, honoring overrides if provided. - let config = match self - .config_manager - .load_for_cwd(request_overrides, typesafe_overrides, history_cwd) - .await - { - Ok(config) => config, - Err(err) => { - self.outgoing - .send_error(request_id, config_load_error(&err)) - .await; - return; - } - }; - - let fallback_model_provider = config.model_provider_id.clone(); - let instruction_sources = Self::instruction_sources_from_config(&config).await; - - let NewThread { - thread_id, - thread: forked_thread, - session_configured, - .. - } = match self - .thread_manager - .fork_thread( - ForkSnapshot::Interrupted, - config, - rollout_path.clone(), - persist_extended_history, - self.request_trace_context(&request_id).await, - ) - .await - { - Ok(thread) => thread, - Err(err) => { - match err { + let NewThread { + thread_id, + thread: forked_thread, + session_configured, + .. + } = self + .thread_manager + .fork_thread_from_history( + ForkSnapshot::Interrupted, + config, + InitialHistory::Resumed(ResumedHistory { + conversation_id: source_thread_id, + history: history_items.clone(), + rollout_path: source_thread.rollout_path.clone(), + }), + persist_extended_history, + self.request_trace_context(&request_id).await, + ) + .await + .map_err(|err| match err { CodexErr::Io(_) | CodexErr::Json(_) => { - self.send_invalid_request_error( - request_id, - format!("failed to load rollout `{}`: {err}", rollout_path.display()), - ) - .await; + invalid_request(format!("failed to load thread {source_thread_id}: {err}")) } - CodexErr::InvalidRequest(message) => { - self.send_invalid_request_error(request_id, message).await; - } - _ => { - self.send_internal_error( - request_id, - format!("error forking thread: {err}"), - ) - .await; - } - } - return; - } - }; + CodexErr::InvalidRequest(message) => invalid_request(message), + err => internal_error(format!("error forking thread: {err}")), + })?; - // Auto-attach a conversation listener when forking a thread. - Self::log_listener_attach_result( - self.ensure_conversation_listener( + // Auto-attach a conversation listener when forking a thread. + Self::log_listener_attach_result( + self.ensure_conversation_listener( + thread_id, + request_id.connection_id, + /*raw_events_enabled*/ false, + ApiVersion::V2, + ) + .await, thread_id, request_id.connection_id, - /*raw_events_enabled*/ false, - ApiVersion::V2, - ) - .await, - thread_id, - request_id.connection_id, - "thread", - ); - - // Persistent forks materialize their own rollout immediately. Ephemeral forks stay - // pathless, so they rebuild their visible history from the copied source rollout instead. - let mut thread = if let Some(fork_rollout_path) = session_configured.rollout_path.as_ref() { - match read_summary_from_rollout( - fork_rollout_path.as_path(), - fallback_model_provider.as_str(), - ) - .await - { - Ok(summary) => { - let mut thread = summary_to_thread(summary, &self.config.cwd); - thread.forked_from_id = - forked_from_id_from_rollout(fork_rollout_path.as_path()).await; - thread - } - Err(err) => { - self.send_internal_error( - request_id, - format!( - "failed to load rollout `{}` for thread {thread_id}: {err}", - fork_rollout_path.display() - ), - ) - .await; - return; - } - } - } else { - let config_snapshot = forked_thread.config_snapshot().await; - // forked thread names do not inherit the source thread name - let mut thread = - build_thread_from_snapshot(thread_id, &config_snapshot, /*path*/ None); - let history_items = match read_rollout_items_from_rollout(rollout_path.as_path()).await - { - Ok(items) => items, - Err(err) => { - self.send_internal_error( - request_id, - format!( - "failed to load source rollout `{}` for thread {thread_id}: {err}", - rollout_path.display() - ), - ) - .await; - return; - } - }; - thread.preview = preview_from_rollout_items(&history_items); - thread.forked_from_id = source_thread_id - .or_else(|| { - history_items.iter().find_map(|item| match item { - RolloutItem::SessionMeta(meta_line) => Some(meta_line.meta.id), - _ => None, - }) - }) - .map(|id| id.to_string()); - if let Err(message) = populate_thread_turns( - &mut thread, - ThreadTurnSource::HistoryItems(&history_items), - /*active_turn*/ None, - ) - .await - { - self.send_internal_error(request_id, message).await; - return; - } - thread - }; - - if let Some(fork_rollout_path) = session_configured.rollout_path.as_ref() - && let Err(message) = populate_thread_turns( - &mut thread, - ThreadTurnSource::RolloutPath(fork_rollout_path.as_path()), - /*active_turn*/ None, - ) - .await - { - self.send_internal_error(request_id, message).await; - return; - } - - self.thread_watch_manager - .upsert_thread_silently(thread.clone()) - .await; - - thread.status = resolve_thread_status( - self.thread_watch_manager - .loaded_status_for_thread(&thread.id) - .await, - /*has_in_progress_turn*/ false, - ); - - let response = ThreadForkResponse { - thread: thread.clone(), - model: session_configured.model, - model_provider: session_configured.model_provider_id, - service_tier: session_configured.service_tier, - cwd: session_configured.cwd, - instruction_sources, - approval_policy: session_configured.approval_policy.into(), - approvals_reviewer: session_configured.approvals_reviewer.into(), - sandbox: session_configured.sandbox_policy.into(), - reasoning_effort: session_configured.reasoning_effort, - }; - if self.config.features.enabled(Feature::GeneralAnalytics) { - self.analytics_events_client.track_response( - request_id.connection_id.0, - ClientResponse::ThreadFork { - request_id: request_id.request_id.clone(), - response: response.clone(), - }, + "thread", ); - } - let connection_id = request_id.connection_id; - let token_usage_thread = response.thread.clone(); - let token_usage_turn_id = if let Some(turn_id) = - latest_token_usage_turn_id_for_thread_path(&token_usage_thread).await - { - Some(turn_id) - } else { - latest_token_usage_turn_id_from_rollout_path( - rollout_path.as_path(), - &token_usage_thread, - ) - .await - }; - self.outgoing.send_response(request_id, response).await; - // Mirror the resume contract for forks: the new thread is usable as soon - // as the response arrives, so restored usage must follow immediately. - send_thread_token_usage_update_to_connection( - &self.outgoing, - connection_id, - thread_id, - &token_usage_thread, - forked_thread.as_ref(), - token_usage_turn_id, - ) + // Persistent forks materialize their own rollout immediately. Ephemeral forks stay + // pathless, so they rebuild their visible history from the copied source history instead. + let mut thread = + if let Some(fork_rollout_path) = session_configured.rollout_path.as_ref() { + let stored_thread = self + .read_stored_thread_for_new_fork( + fork_thread_store.as_ref(), + thread_id, + include_turns, + ) + .await?; + self.stored_thread_to_api_thread( + stored_thread, + fallback_model_provider.as_str(), + include_turns, + ) + .await + .map_err(|message| { + internal_error(format!( + "failed to load rollout `{}` for thread {thread_id}: {message}", + fork_rollout_path.display() + )) + })? + } else { + let config_snapshot = forked_thread.config_snapshot().await; + // forked thread names do not inherit the source thread name + let mut thread = + build_thread_from_snapshot(thread_id, &config_snapshot, /*path*/ None); + thread.preview = preview_from_rollout_items(&history_items); + thread.forked_from_id = Some(source_thread_id.to_string()); + if include_turns { + populate_thread_turns( + &mut thread, + ThreadTurnSource::HistoryItems(&history_items), + /*active_turn*/ None, + ) + .await + .map_err(internal_error)?; + } + thread + }; + + self.thread_watch_manager + .upsert_thread_silently(thread.clone()) + .await; + + thread.status = resolve_thread_status( + self.thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await, + /*has_in_progress_turn*/ false, + ); + let permission_profile = thread_response_permission_profile( + forked_thread.config_snapshot().await.permission_profile, + ); + + let response = ThreadForkResponse { + thread: thread.clone(), + model: session_configured.model, + model_provider: session_configured.model_provider_id, + service_tier: session_configured.service_tier, + cwd: session_configured.cwd, + instruction_sources, + approval_policy: session_configured.approval_policy.into(), + approvals_reviewer: session_configured.approvals_reviewer.into(), + sandbox: session_configured.sandbox_policy.into(), + permission_profile, + reasoning_effort: session_configured.reasoning_effort, + }; + + Ok::<_, JSONRPCErrorError>(( + response, + thread_id, + forked_thread, + history_items, + thread_started_notification(thread), + )) + } .await; - let notif = ThreadStartedNotification { thread }; + let (response, thread_id, forked_thread, history_items, notif) = match result { + Ok(value) => value, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::ThreadFork { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + + let connection_id = request_id.connection_id; + let token_usage_thread = include_turns.then(|| response.thread.clone()); + self.outgoing.send_response(request_id, response).await; + // `excludeTurns` is the cheap fork path, so skip restored usage replay + // instead of rebuilding history only to attribute a historical update. + if let Some(token_usage_thread) = token_usage_thread { + let token_usage_turn_id = if let Some(turn_id) = + latest_token_usage_turn_id_for_thread_path(&token_usage_thread).await + { + Some(turn_id) + } else { + latest_token_usage_turn_id_from_rollout_items( + &history_items, + token_usage_thread.turns.as_slice(), + ) + }; + // Mirror the resume contract for forks: the new thread is usable as soon + // as the response arrives, so restored usage must follow immediately. + send_thread_token_usage_update_to_connection( + &self.outgoing, + connection_id, + thread_id, + &token_usage_thread, + forked_thread.as_ref(), + token_usage_turn_id, + ) + .await; + } + self.outgoing .send_server_notification(ServerNotification::ThreadStarted(notif)) .await; @@ -5116,6 +4914,14 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: GetConversationSummaryParams, ) { + let result = self.get_thread_summary_response(params).await; + self.outgoing.send_result(request_id, result).await; + } + + async fn get_thread_summary_response( + &self, + params: GetConversationSummaryParams, + ) -> Result { let fallback_provider = self.config.model_provider_id.as_str(); let read_result = match params { GetConversationSummaryParams::ThreadId { conversation_id } => self @@ -5133,14 +4939,9 @@ impl CodexMessageProcessor { .as_any() .downcast_ref::() else { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: - "rollout path queries are only supported with the local thread store" - .to_string(), - data: None, - }; - return self.outgoing.send_error(request_id, error).await; + return Err(invalid_request( + "rollout path queries are only supported with the local thread store", + )); }; local_thread_store @@ -5154,27 +4955,14 @@ impl CodexMessageProcessor { } }; - match read_result { - Ok(stored_thread) => { - let Some(summary) = summary_from_stored_thread(stored_thread, fallback_provider) - else { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: - "failed to load conversation summary: thread is missing rollout path" - .to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - }; - let response = GetConversationSummaryResponse { summary }; - self.outgoing.send_response(request_id, response).await; - } - Err(error) => { - self.outgoing.send_error(request_id, error).await; - } - } + let stored_thread = read_result?; + let summary = + summary_from_stored_thread(stored_thread, fallback_provider).ok_or_else(|| { + internal_error( + "failed to load conversation summary: thread is missing rollout path", + ) + })?; + Ok(GetConversationSummaryResponse { summary }) } async fn list_threads_common( @@ -5189,8 +4977,9 @@ impl CodexMessageProcessor { model_providers, source_kinds, archived, - cwd, + cwd_filters, search_term, + use_state_db_only, } = filters; let mut cursor_obj = cursor; let mut last_cursor = cursor_obj.clone(); @@ -5227,28 +5016,27 @@ impl CodexMessageProcessor { sort_direction: store_sort_direction, allowed_sources: allowed_sources.to_vec(), model_providers: model_provider_filter.clone(), + cwd_filters: cwd_filters.clone(), archived, search_term: search_term.clone(), + use_state_db_only, }) .await .map_err(thread_store_list_error)?; - let mut candidate_summaries = Vec::with_capacity(page.items.len()); + let mut filtered = Vec::with_capacity(page.items.len()); for it in page.items { let Some(summary) = summary_from_stored_thread(it, fallback_provider.as_str()) else { continue; }; - candidate_summaries.push(summary); - } - - let mut filtered = Vec::with_capacity(candidate_summaries.len()); - for summary in candidate_summaries { if source_kind_filter .as_ref() .is_none_or(|filter| source_kind_matches(&summary.source, filter)) - && cwd.as_ref().is_none_or(|expected_cwd| { - path_utils::paths_match_after_normalization(&summary.cwd, expected_cwd) + && cwd_filters.as_ref().is_none_or(|expected_cwds| { + expected_cwds.iter().any(|expected_cwd| { + path_utils::paths_match_after_normalization(&summary.cwd, expected_cwd) + }) }) { filtered.push(summary); @@ -5260,25 +5048,22 @@ impl CodexMessageProcessor { items.extend(filtered); remaining = requested_page_size.saturating_sub(items.len()); - let next_cursor_value = page.next_cursor.clone(); - next_cursor = next_cursor_value.clone(); + next_cursor = page.next_cursor; if remaining == 0 { break; } - match next_cursor_value { - Some(cursor_val) if remaining > 0 => { - // Break if our pagination would reuse the same cursor again; this avoids - // an infinite loop when filtering drops everything on the page. - if last_cursor.as_ref() == Some(&cursor_val) { - next_cursor = None; - break; - } - last_cursor = Some(cursor_val.clone()); - cursor_obj = Some(cursor_val); - } - _ => break, + let Some(cursor_val) = next_cursor.clone() else { + break; + }; + // Break if our pagination would reuse the same cursor again; this avoids + // an infinite loop when filtering drops everything on the page. + if last_cursor.as_ref() == Some(&cursor_val) { + next_cursor = None; + break; } + last_cursor = Some(cursor_val.clone()); + cursor_obj = Some(cursor_val); } Ok((items, next_cursor)) @@ -5290,63 +5075,51 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: ModelListParams, ) { - let ModelListParams { - limit, - cursor, - include_hidden, - } = params; - let models = supported_models(thread_manager, include_hidden.unwrap_or(false)).await; - let total = models.len(); + let result = async { + let ModelListParams { + limit, + cursor, + include_hidden, + } = params; + let models = supported_models(thread_manager, include_hidden.unwrap_or(false)).await; + let total = models.len(); - if total == 0 { - let response = ModelListResponse { - data: Vec::new(), - next_cursor: None, + if total == 0 { + return Ok(ModelListResponse { + data: Vec::new(), + next_cursor: None, + }); + } + + let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; + let effective_limit = effective_limit.min(total); + let start = match cursor { + Some(cursor) => cursor + .parse::() + .map_err(|_| invalid_request(format!("invalid cursor: {cursor}")))?, + None => 0, }; - outgoing.send_response(request_id, response).await; - return; - } - let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; - let effective_limit = effective_limit.min(total); - let start = match cursor { - Some(cursor) => match cursor.parse::() { - Ok(idx) => idx, - Err(_) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid cursor: {cursor}"), - data: None, - }; - outgoing.send_error(request_id, error).await; - return; - } - }, - None => 0, - }; + if start > total { + return Err(invalid_request(format!( + "cursor {start} exceeds total models {total}" + ))); + } - if start > total { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("cursor {start} exceeds total models {total}"), - data: None, + let end = start.saturating_add(effective_limit).min(total); + let items = models[start..end].to_vec(); + let next_cursor = if end < total { + Some(end.to_string()) + } else { + None }; - outgoing.send_error(request_id, error).await; - return; + Ok::<_, JSONRPCErrorError>(ModelListResponse { + data: items, + next_cursor, + }) } - - let end = start.saturating_add(effective_limit).min(total); - let items = models[start..end].to_vec(); - let next_cursor = if end < total { - Some(end.to_string()) - } else { - None - }; - let response = ModelListResponse { - data: items, - next_cursor, - }; - outgoing.send_response(request_id, response).await; + .await; + outgoing.send_result(request_id, result).await; } async fn list_collaboration_modes( @@ -5370,14 +5143,20 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: ExperimentalFeatureListParams, ) { + let result = self.experimental_feature_list_response(params).await; + self.outgoing.send_result(request_id, result).await; + } + + async fn experimental_feature_list_response( + &self, + params: ExperimentalFeatureListParams, + ) -> Result { let ExperimentalFeatureListParams { cursor, limit } = params; - let config = match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => config, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let auth = self.auth_manager.auth().await; + let workspace_codex_plugins_enabled = self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await; let data = FEATURES .iter() @@ -5412,7 +5191,9 @@ impl CodexMessageProcessor { display_name, description, announcement, - enabled: config.features.enabled(spec.id), + enabled: config.features.enabled(spec.id) + && (workspace_codex_plugins_enabled + || !matches!(spec.id, Feature::Apps | Feature::Plugins)), default_enabled: spec.default_enabled, } }) @@ -5420,16 +5201,10 @@ impl CodexMessageProcessor { let total = data.len(); if total == 0 { - self.outgoing - .send_response( - request_id, - ExperimentalFeatureListResponse { - data: Vec::new(), - next_cursor: None, - }, - ) - .await; - return; + return Ok(ExperimentalFeatureListResponse { + data: Vec::new(), + next_cursor: None, + }); } // Clamp to 1 so limit=0 cannot return a non-advancing page. @@ -5438,25 +5213,15 @@ impl CodexMessageProcessor { let start = match cursor { Some(cursor) => match cursor.parse::() { Ok(idx) => idx, - Err(_) => { - self.send_invalid_request_error( - request_id, - format!("invalid cursor: {cursor}"), - ) - .await; - return; - } + Err(_) => return Err(invalid_request(format!("invalid cursor: {cursor}"))), }, None => 0, }; if start > total { - self.send_invalid_request_error( - request_id, - format!("cursor {start} exceeds total feature flags {total}"), - ) - .await; - return; + return Err(invalid_request(format!( + "cursor {start} exceeds total feature flags {total}" + ))); } let end = start.saturating_add(effective_limit).min(total); @@ -5467,12 +5232,7 @@ impl CodexMessageProcessor { None }; - self.outgoing - .send_response( - request_id, - ExperimentalFeatureListResponse { data, next_cursor }, - ) - .await; + Ok(ExperimentalFeatureListResponse { data, next_cursor }) } async fn mock_experimental_method( @@ -5486,21 +5246,13 @@ impl CodexMessageProcessor { } async fn mcp_server_refresh(&self, request_id: ConnectionRequestId, _params: Option<()>) { - let config = match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => config, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; - - if let Err(error) = self.queue_mcp_server_refresh_for_config(&config).await { - self.outgoing.send_error(request_id, error).await; - return; + let result = async { + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + self.queue_mcp_server_refresh_for_config(&config).await?; + Ok::<_, JSONRPCErrorError>(McpServerRefreshResponse {}) } - - let response = McpServerRefreshResponse {}; - self.outgoing.send_response(request_id, response).await; + .await; + self.outgoing.send_result(request_id, result).await; } async fn queue_mcp_server_refresh_for_config( @@ -5554,14 +5306,15 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: McpServerOauthLoginParams, ) { - let config = match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => config, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; + let result = self.mcp_server_oauth_login_response(params).await; + self.outgoing.send_result(request_id, result).await; + } + async fn mcp_server_oauth_login_response( + &self, + params: McpServerOauthLoginParams, + ) -> Result { + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; let McpServerOauthLoginParams { name, scopes, @@ -5574,13 +5327,9 @@ impl CodexMessageProcessor { .configured_servers(&config) .await; let Some(server) = configured_servers.get(&name) else { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("No MCP server named '{name}' found."), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; + return Err(invalid_request(format!( + "No MCP server named '{name}' found." + ))); }; let (url, http_headers, env_http_headers) = match &server.transport { @@ -5591,14 +5340,9 @@ impl CodexMessageProcessor { .. } => (url.clone(), http_headers.clone(), env_http_headers.clone()), _ => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "OAuth login is only supported for streamable HTTP servers." - .to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; + return Err(invalid_request( + "OAuth login is only supported for streamable HTTP servers.", + )); } }; @@ -5610,7 +5354,7 @@ impl CodexMessageProcessor { let resolved_scopes = resolve_oauth_scopes(scopes, server.scopes.clone(), discovered_scopes); - match perform_oauth_login_return_url( + let handle = perform_oauth_login_return_url( &name, &url, config.mcp_oauth_credentials_store_mode, @@ -5623,40 +5367,28 @@ impl CodexMessageProcessor { config.mcp_oauth_callback_url.as_deref(), ) .await - { - Ok(handle) => { - let authorization_url = handle.authorization_url().to_string(); - let notification_name = name.clone(); - let outgoing = Arc::clone(&self.outgoing); + .map_err(|err| internal_error(format!("failed to login to MCP server '{name}': {err}")))?; + let authorization_url = handle.authorization_url().to_string(); + let notification_name = name.clone(); + let outgoing = Arc::clone(&self.outgoing); - tokio::spawn(async move { - let (success, error) = match handle.wait().await { - Ok(()) => (true, None), - Err(err) => (false, Some(err.to_string())), - }; + tokio::spawn(async move { + let (success, error) = match handle.wait().await { + Ok(()) => (true, None), + Err(err) => (false, Some(err.to_string())), + }; - let notification = ServerNotification::McpServerOauthLoginCompleted( - McpServerOauthLoginCompletedNotification { - name: notification_name, - success, - error, - }, - ); - outgoing.send_server_notification(notification).await; - }); + let notification = ServerNotification::McpServerOauthLoginCompleted( + McpServerOauthLoginCompletedNotification { + name: notification_name, + success, + error, + }, + ); + outgoing.send_server_notification(notification).await; + }); - let response = McpServerOauthLoginResponse { authorization_url }; - self.outgoing.send_response(request_id, response).await; - } - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to login to MCP server '{name}': {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - } + Ok(McpServerOauthLoginResponse { authorization_url }) } async fn list_mcp_server_status( @@ -5714,6 +5446,26 @@ impl CodexMessageProcessor { auth: Option, runtime_environment: McpRuntimeEnvironment, ) { + let result = Self::list_mcp_server_status_response( + request_id.request_id.to_string(), + params, + config, + mcp_config, + auth, + runtime_environment, + ) + .await; + outgoing.send_result(request_id, result).await; + } + + async fn list_mcp_server_status_response( + request_id: String, + params: ListMcpServerStatusParams, + config: Config, + mcp_config: codex_mcp::McpConfig, + auth: Option, + runtime_environment: McpRuntimeEnvironment, + ) -> Result { let detail = match params.detail.unwrap_or(McpServerStatusDetail::Full) { McpServerStatusDetail::Full => McpSnapshotDetail::Full, McpServerStatusDetail::ToolsAndAuthOnly => McpSnapshotDetail::ToolsAndAuthOnly, @@ -5722,7 +5474,7 @@ impl CodexMessageProcessor { let snapshot = collect_mcp_server_status_snapshot_with_detail( &mcp_config, auth.as_ref(), - request_id.request_id.to_string(), + request_id, runtime_environment, detail, ) @@ -5757,27 +5509,15 @@ impl CodexMessageProcessor { let start = match params.cursor { Some(cursor) => match cursor.parse::() { Ok(idx) => idx, - Err(_) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid cursor: {cursor}"), - data: None, - }; - outgoing.send_error(request_id, error).await; - return; - } + Err(_) => return Err(invalid_request(format!("invalid cursor: {cursor}"))), }, None => 0, }; if start > total { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("cursor {start} exceeds total MCP servers {total}"), - data: None, - }; - outgoing.send_error(request_id, error).await; - return; + return Err(invalid_request(format!( + "cursor {start} exceeds total MCP servers {total}" + ))); } let end = start.saturating_add(effective_limit).min(total); @@ -5803,9 +5543,7 @@ impl CodexMessageProcessor { None }; - let response = ListMcpServerStatusResponse { data, next_cursor }; - - outgoing.send_response(request_id, response).await; + Ok(ListMcpServerStatusResponse { data, next_cursor }) } async fn read_mcp_resource( @@ -5879,39 +5617,16 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, result: anyhow::Result, ) { - match result { - Ok(result) => match serde_json::from_value::(result) { - Ok(response) => { - outgoing.send_response(request_id, response).await; - } - Err(error) => { - outgoing - .send_error( - request_id, - JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!( - "failed to deserialize MCP resource read response: {error}" - ), - data: None, - }, - ) - .await; - } - }, - Err(error) => { - outgoing - .send_error( - request_id, - JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("{error:#}"), - data: None, - }, - ) - .await; - } - } + let result = result + .map_err(|error| internal_error(format!("{error:#}"))) + .and_then(|result| { + serde_json::from_value::(result).map_err(|error| { + internal_error(format!( + "failed to deserialize MCP resource read response: {error}" + )) + }) + }); + outgoing.send_result(request_id, result).await; } async fn call_mcp_server_tool( @@ -5933,36 +5648,27 @@ impl CodexMessageProcessor { tokio::spawn(async move { let result = thread .call_mcp_tool(¶ms.server, ¶ms.tool, params.arguments, meta) - .await; - match result { - Ok(result) => { - outgoing - .send_response(request_id, McpServerToolCallResponse::from(result)) - .await; - } - Err(error) => { - outgoing - .send_error( - request_id, - JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("{error:#}"), - data: None, - }, - ) - .await; - } - } + .await + .map(McpServerToolCallResponse::from) + .map_err(|error| internal_error(format!("{error:#}"))); + outgoing.send_result(request_id, result).await; }); } - async fn send_invalid_request_error(&self, request_id: ConnectionRequestId, message: String) { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message, - data: None, - }; - self.outgoing.send_error(request_id, error).await; + async fn send_optional_result( + &self, + request_id: ConnectionRequestId, + result: Result, JSONRPCErrorError>, + ) where + T: serde::Serialize, + { + match result { + Ok(Some(response)) => self.outgoing.send_response(request_id, response).await, + Ok(None) => {} + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + } } fn input_too_large_error(actual_chars: usize) -> JSONRPCErrorError { @@ -5987,41 +5693,6 @@ impl CodexMessageProcessor { Ok(()) } - async fn send_internal_error(&self, request_id: ConnectionRequestId, message: String) { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message, - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - - async fn send_marketplace_error( - &self, - request_id: ConnectionRequestId, - err: MarketplaceError, - action: &str, - ) { - match err { - MarketplaceError::MarketplaceNotFound { .. } => { - self.send_invalid_request_error(request_id, err.to_string()) - .await; - } - MarketplaceError::Io { .. } => { - self.send_internal_error(request_id, format!("failed to {action}: {err}")) - .await; - } - MarketplaceError::InvalidMarketplaceFile { .. } - | MarketplaceError::PluginNotFound { .. } - | MarketplaceError::PluginNotAvailable { .. } - | MarketplaceError::PluginsDisabled - | MarketplaceError::InvalidPlugin(_) => { - self.send_invalid_request_error(request_id, err.to_string()) - .await; - } - } - } - async fn wait_for_thread_shutdown(thread: &Arc) -> ThreadShutdownResult { match tokio::time::timeout(Duration::from_secs(10), thread.shutdown_and_wait()).await { Ok(Ok(())) => ThreadShutdownResult::Complete, @@ -6100,34 +5771,33 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: ThreadUnsubscribeParams, ) { - let thread_id = match ThreadId::from_string(¶ms.thread_id) { - Ok(id) => id, - Err(err) => { - self.send_invalid_request_error(request_id, format!("invalid thread id: {err}")) - .await; - return; - } - }; + let result = self + .thread_unsubscribe_response(params, request_id.connection_id) + .await; + self.outgoing.send_result(request_id, result).await; + } + + async fn thread_unsubscribe_response( + &self, + params: ThreadUnsubscribeParams, + connection_id: ConnectionId, + ) -> Result { + let thread_id = ThreadId::from_string(¶ms.thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; if self.thread_manager.get_thread(thread_id).await.is_err() { // Reconcile stale app-server bookkeeping when the thread has already been // removed from the core manager. This keeps loaded-status/subscription state // consistent with the source of truth before reporting NotLoaded. self.finalize_thread_teardown(thread_id).await; - self.outgoing - .send_response( - request_id, - ThreadUnsubscribeResponse { - status: ThreadUnsubscribeStatus::NotLoaded, - }, - ) - .await; - return; + return Ok(ThreadUnsubscribeResponse { + status: ThreadUnsubscribeStatus::NotLoaded, + }); }; let was_subscribed = self .thread_state_manager - .unsubscribe_connection_from_thread(thread_id, request_id.connection_id) + .unsubscribe_connection_from_thread(thread_id, connection_id) .await; let status = if was_subscribed { @@ -6135,9 +5805,7 @@ impl CodexMessageProcessor { } else { ThreadUnsubscribeStatus::NotSubscribed }; - self.outgoing - .send_response(request_id, ThreadUnsubscribeResponse { status }) - .await; + Ok(ThreadUnsubscribeResponse { status }) } async fn prepare_thread_for_archive(&self, thread_id: ThreadId) { @@ -6186,7 +5854,23 @@ impl CodexMessageProcessor { let auth = self.auth_manager.auth().await; if !config .features - .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth)) + .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend)) + { + self.outgoing + .send_response( + request_id, + AppsListResponse { + data: Vec::new(), + next_cursor: None, + }, + ) + .await; + return; + } + + if !self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await { self.outgoing .send_response( @@ -6215,6 +5899,16 @@ impl CodexMessageProcessor { config: Config, environment_manager: Arc, ) { + let result = Self::apps_list_response(&outgoing, params, config, environment_manager).await; + outgoing.send_result(request_id, result).await; + } + + async fn apps_list_response( + outgoing: &Arc, + params: AppsListParams, + config: Config, + environment_manager: Arc, + ) -> Result { let AppsListParams { cursor, limit, @@ -6224,15 +5918,7 @@ impl CodexMessageProcessor { let start = match cursor { Some(cursor) => match cursor.parse::() { Ok(idx) => idx, - Err(_) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid cursor: {cursor}"), - data: None, - }; - outgoing.send_error(request_id, error).await; - return; - } + Err(_) => return Err(invalid_request(format!("invalid cursor: {cursor}"))), }, None => 0, }; @@ -6286,7 +5972,7 @@ impl CodexMessageProcessor { accessible_loaded, all_loaded, ) { - apps_list_helpers::send_app_list_updated_notification(&outgoing, merged.clone()) + apps_list_helpers::send_app_list_updated_notification(outgoing, merged.clone()) .await; last_notified_apps = Some(merged); } @@ -6296,25 +5982,13 @@ impl CodexMessageProcessor { let result = match tokio::time::timeout_at(app_list_deadline, rx.recv()).await { Ok(Some(result)) => result, Ok(None) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: "failed to load app lists".to_string(), - data: None, - }; - outgoing.send_error(request_id, error).await; - return; + return Err(internal_error("failed to load app lists")); } Err(_) => { let timeout_seconds = APP_LIST_LOAD_TIMEOUT.as_secs(); - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!( - "timed out waiting for app lists after {timeout_seconds} seconds" - ), - data: None, - }; - outgoing.send_error(request_id, error).await; - return; + return Err(internal_error(format!( + "timed out waiting for app lists after {timeout_seconds} seconds" + ))); } }; @@ -6324,26 +5998,14 @@ impl CodexMessageProcessor { accessible_loaded = true; } AppListLoadResult::Accessible(Err(err)) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: err, - data: None, - }; - outgoing.send_error(request_id, error).await; - return; + return Err(internal_error(err)); } AppListLoadResult::Directory(Ok(connectors)) => { all_connectors = Some(connectors); all_loaded = true; } AppListLoadResult::Directory(Err(err)) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: err, - data: None, - }; - outgoing.send_error(request_id, error).await; - return; + return Err(internal_error(err)); } } @@ -6373,27 +6035,26 @@ impl CodexMessageProcessor { all_loaded, ) && last_notified_apps.as_ref() != Some(&merged) { - apps_list_helpers::send_app_list_updated_notification(&outgoing, merged.clone()) + apps_list_helpers::send_app_list_updated_notification(outgoing, merged.clone()) .await; last_notified_apps = Some(merged.clone()); } if accessible_loaded && all_loaded { - match apps_list_helpers::paginate_apps(merged.as_slice(), start, limit) { - Ok(response) => { - outgoing.send_response(request_id, response).await; - return; - } - Err(error) => { - outgoing.send_error(request_id, error).await; - return; - } - } + return apps_list_helpers::paginate_apps(merged.as_slice(), start, limit); } } } async fn skills_list(&self, request_id: ConnectionRequestId, params: SkillsListParams) { + let result = self.skills_list_response(params).await; + self.outgoing.send_result(request_id, result).await; + } + + async fn skills_list_response( + &self, + params: SkillsListParams, + ) -> Result { let SkillsListParams { cwds, force_reload, @@ -6418,17 +6079,13 @@ impl CodexMessageProcessor { let mut valid_extra_roots = Vec::new(); for root in entry.extra_user_roots { - let Ok(root) = AbsolutePathBuf::from_absolute_path_checked(root.as_path()) else { - self.send_invalid_request_error( - request_id, - format!( + let root = + AbsolutePathBuf::from_absolute_path_checked(root.as_path()).map_err(|_| { + invalid_request(format!( "skills/list perCwdExtraUserRoots extraUserRoots paths must be absolute: {}", root.display() - ), - ) - .await; - return; - }; + )) + })?; valid_extra_roots.push(root); } extra_roots_by_cwd @@ -6437,13 +6094,11 @@ impl CodexMessageProcessor { .extend(valid_extra_roots); } - let config = match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => config, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let auth = self.auth_manager.auth().await; + let workspace_codex_plugins_enabled = self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await; let skills_manager = self.thread_manager.skills_manager(); let plugins_manager = self.thread_manager.plugins_manager(); let fs = self @@ -6493,7 +6148,7 @@ impl CodexMessageProcessor { let effective_skill_roots = plugins_manager .effective_skill_roots_for_layer_stack( &config_layer_stack, - config.features.enabled(Feature::Plugins), + config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled, ) .await; let skills_input = codex_core::skills::SkillsLoadInput::new( @@ -6518,11 +6173,8 @@ impl CodexMessageProcessor { errors, }); } - self.outgoing - .send_response(request_id, SkillsListResponse { data }) - .await; + Ok(SkillsListResponse { data }) } - async fn marketplace_remove( &self, request_id: ConnectionRequestId, @@ -6534,278 +6186,77 @@ impl CodexMessageProcessor { marketplace_name: params.marketplace_name, }, ) - .await; - - match result { - Ok(outcome) => { - self.outgoing - .send_response( - request_id, - MarketplaceRemoveResponse { - marketplace_name: outcome.marketplace_name, - installed_root: outcome.removed_installed_root, - }, - ) - .await; - } - Err(MarketplaceRemoveError::InvalidRequest(message)) => { - self.send_invalid_request_error(request_id, message).await; - } - Err(MarketplaceRemoveError::Internal(message)) => { - self.send_internal_error(request_id, message).await; - } - } + .await + .map(|outcome| MarketplaceRemoveResponse { + marketplace_name: outcome.marketplace_name, + installed_root: outcome.removed_installed_root, + }) + .map_err(|err| match err { + MarketplaceRemoveError::InvalidRequest(message) => invalid_request(message), + MarketplaceRemoveError::Internal(message) => internal_error(message), + }); + self.outgoing.send_result(request_id, result).await; } - async fn plugin_list(&self, request_id: ConnectionRequestId, params: PluginListParams) { + async fn marketplace_upgrade( + &self, + request_id: ConnectionRequestId, + params: MarketplaceUpgradeParams, + ) { + let result = self.marketplace_upgrade_response(params).await; + self.outgoing.send_result(request_id, result).await; + } + + async fn marketplace_upgrade_response( + &self, + params: MarketplaceUpgradeParams, + ) -> Result { + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; let plugins_manager = self.thread_manager.plugins_manager(); - let PluginListParams { cwds } = params; - let roots = cwds.unwrap_or_default(); - plugins_manager.maybe_start_non_curated_plugin_cache_refresh(&roots); + let MarketplaceUpgradeParams { marketplace_name } = params; - let config = match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => config, - Err(err) => { - self.outgoing.send_error(request_id, err).await; - return; - } - }; - let auth = self.auth_manager.auth().await; - - let config_for_marketplace_listing = config.clone(); - let plugins_manager_for_marketplace_listing = plugins_manager.clone(); - let (data, marketplace_load_errors) = match tokio::task::spawn_blocking(move || { - let outcome = plugins_manager_for_marketplace_listing - .list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?; - Ok::< - ( - Vec, - Vec, - ), - MarketplaceError, - >(( - outcome - .marketplaces - .into_iter() - .map(|marketplace| PluginMarketplaceEntry { - name: marketplace.name, - path: Some(marketplace.path), - interface: marketplace.interface.map(|interface| MarketplaceInterface { - display_name: interface.display_name, - }), - plugins: marketplace - .plugins - .into_iter() - .map(|plugin| PluginSummary { - id: plugin.id, - installed: plugin.installed, - enabled: plugin.enabled, - name: plugin.name, - source: marketplace_plugin_source_to_info(plugin.source), - install_policy: plugin.policy.installation.into(), - auth_policy: plugin.policy.authentication.into(), - interface: plugin.interface.map(local_plugin_interface_to_info), - }) - .collect(), - }) - .collect(), - outcome - .errors - .into_iter() - .map(|err| codex_app_server_protocol::MarketplaceLoadErrorInfo { - marketplace_path: err.path, - message: err.message, - }) - .collect(), - )) + let outcome = tokio::task::spawn_blocking(move || { + plugins_manager + .upgrade_configured_marketplaces_for_config(&config, marketplace_name.as_deref()) }) .await - { - Ok(Ok(outcome)) => outcome, - Ok(Err(err)) => { - self.send_marketplace_error(request_id, err, "list marketplace plugins") - .await; - return; - } - Err(err) => { - self.send_internal_error( - request_id, - format!("failed to list marketplace plugins: {err}"), - ) - .await; - return; - } - }; + .map_err(|err| internal_error(format!("failed to upgrade marketplaces: {err}")))? + .map_err(invalid_request)?; - let featured_plugin_ids = if data - .iter() - .any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) - { - match plugins_manager - .featured_plugin_ids_for_config(&config, auth.as_ref()) - .await - { - Ok(featured_plugin_ids) => featured_plugin_ids, - Err(err) => { - warn!( - error = %err, - "plugin/list featured plugin fetch failed; returning empty featured ids" - ); - Vec::new() - } - } - } else { - Vec::new() - }; - - self.outgoing - .send_response( - request_id, - PluginListResponse { - marketplaces: data, - marketplace_load_errors, - featured_plugin_ids, - }, - ) - .await; + Ok(MarketplaceUpgradeResponse { + selected_marketplaces: outcome.selected_marketplaces, + upgraded_roots: outcome.upgraded_roots, + errors: outcome + .errors + .into_iter() + .map(|err| MarketplaceUpgradeErrorInfo { + marketplace_name: err.marketplace_name, + message: err.message, + }) + .collect(), + }) } async fn marketplace_add(&self, request_id: ConnectionRequestId, params: MarketplaceAddParams) { let result = add_marketplace_to_codex_home( self.config.codex_home.to_path_buf(), - codex_core::plugins::MarketplaceAddRequest { + MarketplaceAddRequest { source: params.source, ref_name: params.ref_name, sparse_paths: params.sparse_paths.unwrap_or_default(), }, ) - .await; - - match result { - Ok(outcome) => { - self.outgoing - .send_response( - request_id, - MarketplaceAddResponse { - marketplace_name: outcome.marketplace_name, - installed_root: outcome.installed_root, - already_added: outcome.already_added, - }, - ) - .await; - } - Err(MarketplaceAddError::InvalidRequest(message)) => { - self.send_invalid_request_error(request_id, message).await; - } - Err(MarketplaceAddError::Internal(message)) => { - self.send_internal_error(request_id, message).await; - } - } - } - - async fn plugin_read(&self, request_id: ConnectionRequestId, params: PluginReadParams) { - let plugins_manager = self.thread_manager.plugins_manager(); - let PluginReadParams { - marketplace_path, - remote_marketplace_name, - plugin_name, - } = params; - let marketplace_path = match (marketplace_path, remote_marketplace_name) { - (Some(marketplace_path), None) => marketplace_path, - (None, Some(remote_marketplace_name)) => { - self.outgoing - .send_error( - request_id, - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!( - "remote plugin read is not supported yet for marketplace {remote_marketplace_name}" - ), - data: None, - }, - ) - .await; - return; - } - (Some(_), Some(_)) | (None, None) => { - self.outgoing - .send_error( - request_id, - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "plugin/read requires exactly one of marketplacePath or remoteMarketplaceName".to_string(), - data: None, - }, - ) - .await; - return; - } - }; - let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf); - - let config = match self.load_latest_config(config_cwd).await { - Ok(config) => config, - Err(err) => { - self.outgoing.send_error(request_id, err).await; - return; - } - }; - - let request = PluginReadRequest { - plugin_name, - marketplace_path, - }; - let outcome = match plugins_manager - .read_plugin_for_config(&config, &request) - .await - { - Ok(outcome) => outcome, - Err(err) => { - self.send_marketplace_error(request_id, err, "read plugin details") - .await; - return; - } - }; - let environment_manager = self.thread_manager.environment_manager(); - let app_summaries = plugin_app_helpers::load_plugin_app_summaries( - &config, - &outcome.plugin.apps, - &environment_manager, - ) - .await; - let visible_skills = outcome - .plugin - .skills - .iter() - .filter(|skill| { - skill.matches_product_restriction_for_product( - self.thread_manager.session_source().restriction_product(), - ) - }) - .cloned() - .collect::>(); - let plugin = PluginDetail { + .await + .map(|outcome| MarketplaceAddResponse { marketplace_name: outcome.marketplace_name, - marketplace_path: outcome.marketplace_path, - summary: PluginSummary { - id: outcome.plugin.id, - name: outcome.plugin.name, - source: marketplace_plugin_source_to_info(outcome.plugin.source), - installed: outcome.plugin.installed, - enabled: outcome.plugin.enabled, - install_policy: outcome.plugin.policy.installation.into(), - auth_policy: outcome.plugin.policy.authentication.into(), - interface: outcome.plugin.interface.map(local_plugin_interface_to_info), - }, - description: outcome.plugin.description, - skills: plugin_skills_to_info(&visible_skills, &outcome.plugin.disabled_skill_paths), - apps: app_summaries, - mcp_servers: outcome.plugin.mcp_server_names, - }; - - self.outgoing - .send_response(request_id, PluginReadResponse { plugin }) - .await; + installed_root: outcome.installed_root, + already_added: outcome.already_added, + }) + .map_err(|err| match err { + MarketplaceAddError::InvalidRequest(message) => invalid_request(message), + MarketplaceAddError::Internal(message) => internal_error(message), + }); + self.outgoing.send_result(request_id, result).await; } async fn skills_config_write( @@ -6813,6 +6264,14 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: SkillsConfigWriteParams, ) { + let result = self.skills_config_write_response(params).await; + self.outgoing.send_result(request_id, result).await; + } + + async fn skills_config_write_response( + &self, + params: SkillsConfigWriteParams, + ) -> Result { let SkillsConfigWriteParams { path, name, @@ -6827,297 +6286,24 @@ impl CodexMessageProcessor { ConfigEdit::SetSkillConfigByName { name, enabled } } _ => { - let error = JSONRPCErrorError { - code: INVALID_PARAMS_ERROR_CODE, - message: "skills/config/write requires exactly one of path or name".to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; + return Err(invalid_params( + "skills/config/write requires exactly one of path or name", + )); } }; let edits = vec![edit]; - let result = ConfigEditsBuilder::new(&self.config.codex_home) + ConfigEditsBuilder::new(&self.config.codex_home) .with_edits(edits) .apply() - .await; - - match result { - Ok(()) => { + .await + .map(|()| { self.thread_manager.plugins_manager().clear_cache(); self.thread_manager.skills_manager().clear_cache(); - self.outgoing - .send_response( - request_id, - SkillsConfigWriteResponse { - effective_enabled: enabled, - }, - ) - .await; - } - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to update skill settings: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - } - } - - async fn plugin_install(&self, request_id: ConnectionRequestId, params: PluginInstallParams) { - let PluginInstallParams { - marketplace_path, - remote_marketplace_name, - plugin_name, - } = params; - let marketplace_path = match (marketplace_path, remote_marketplace_name) { - (Some(marketplace_path), None) => marketplace_path, - (None, Some(remote_marketplace_name)) => { - self.outgoing - .send_error( - request_id, - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!( - "remote plugin install is not supported yet for marketplace {remote_marketplace_name}" - ), - data: None, - }, - ) - .await; - return; - } - (Some(_), Some(_)) | (None, None) => { - self.outgoing - .send_error( - request_id, - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "plugin/install requires exactly one of marketplacePath or remoteMarketplaceName".to_string(), - data: None, - }, - ) - .await; - return; - } - }; - let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf); - - let plugins_manager = self.thread_manager.plugins_manager(); - let request = PluginInstallRequest { - plugin_name, - marketplace_path, - }; - - let install_result = plugins_manager.install_plugin(request).await; - - match install_result { - Ok(result) => { - let config = match self.load_latest_config(config_cwd).await { - Ok(config) => config, - Err(err) => { - warn!( - "failed to reload config after plugin install, using current config: {err:?}" - ); - self.config.as_ref().clone() - } - }; - - self.clear_plugin_related_caches(); - - let plugin_mcp_servers = - load_plugin_mcp_servers(result.installed_path.as_path()).await; - - if !plugin_mcp_servers.is_empty() { - if let Err(err) = self.queue_mcp_server_refresh_for_config(&config).await { - warn!( - plugin = result.plugin_id.as_key(), - "failed to queue MCP refresh after plugin install: {err:?}" - ); - } - self.start_plugin_mcp_oauth_logins(&config, plugin_mcp_servers) - .await; + SkillsConfigWriteResponse { + effective_enabled: enabled, } - - let plugin_apps = load_plugin_apps(result.installed_path.as_path()).await; - let auth = self.auth_manager.auth().await; - let apps_needing_auth = if plugin_apps.is_empty() - || !config.features.apps_enabled_for_auth( - auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth), - ) { - Vec::new() - } else { - let environment_manager = self.thread_manager.environment_manager(); - let (all_connectors_result, accessible_connectors_result) = tokio::join!( - connectors::list_all_connectors_with_options(&config, /*force_refetch*/ true), - connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( - &config, /*force_refetch*/ true, &environment_manager - ), - ); - - let all_connectors = match all_connectors_result { - Ok(connectors) => connectors, - Err(err) => { - warn!( - plugin = result.plugin_id.as_key(), - "failed to load app metadata after plugin install: {err:#}" - ); - connectors::list_cached_all_connectors(&config) - .await - .unwrap_or_default() - } - }; - let all_connectors = - connectors::connectors_for_plugin_apps(all_connectors, &plugin_apps); - let (accessible_connectors, codex_apps_ready) = - match accessible_connectors_result { - Ok(status) => (status.connectors, status.codex_apps_ready), - Err(err) => { - warn!( - plugin = result.plugin_id.as_key(), - "failed to load accessible apps after plugin install: {err:#}" - ); - ( - connectors::list_cached_accessible_connectors_from_mcp_tools( - &config, - ) - .await - .unwrap_or_default(), - false, - ) - } - }; - if !codex_apps_ready { - warn!( - plugin = result.plugin_id.as_key(), - "codex_apps MCP not ready after plugin install; skipping appsNeedingAuth check" - ); - } - - plugin_app_helpers::plugin_apps_needing_auth( - &all_connectors, - &accessible_connectors, - &plugin_apps, - codex_apps_ready, - ) - }; - - self.outgoing - .send_response( - request_id, - PluginInstallResponse { - auth_policy: result.auth_policy.into(), - apps_needing_auth, - }, - ) - .await; - } - Err(err) => { - if err.is_invalid_request() { - self.send_invalid_request_error(request_id, err.to_string()) - .await; - return; - } - - match err { - CorePluginInstallError::Marketplace(err) => { - self.send_marketplace_error(request_id, err, "install plugin") - .await; - } - CorePluginInstallError::Config(err) => { - self.send_internal_error( - request_id, - format!("failed to persist installed plugin config: {err}"), - ) - .await; - } - CorePluginInstallError::Remote(err) => { - self.send_internal_error( - request_id, - format!("failed to enable remote plugin: {err}"), - ) - .await; - } - CorePluginInstallError::Join(err) => { - self.send_internal_error( - request_id, - format!("failed to install plugin: {err}"), - ) - .await; - } - CorePluginInstallError::Store(err) => { - self.send_internal_error( - request_id, - format!("failed to install plugin: {err}"), - ) - .await; - } - } - } - } - } - - async fn plugin_uninstall( - &self, - request_id: ConnectionRequestId, - params: PluginUninstallParams, - ) { - let PluginUninstallParams { plugin_id } = params; - let plugins_manager = self.thread_manager.plugins_manager(); - - let uninstall_result = plugins_manager.uninstall_plugin(plugin_id).await; - - match uninstall_result { - Ok(()) => { - self.clear_plugin_related_caches(); - self.outgoing - .send_response(request_id, PluginUninstallResponse {}) - .await; - } - Err(err) => { - if err.is_invalid_request() { - self.send_invalid_request_error(request_id, err.to_string()) - .await; - return; - } - - match err { - CorePluginUninstallError::Config(err) => { - self.send_internal_error( - request_id, - format!("failed to clear plugin config: {err}"), - ) - .await; - } - CorePluginUninstallError::Remote(err) => { - self.send_internal_error( - request_id, - format!("failed to uninstall remote plugin: {err}"), - ) - .await; - } - CorePluginUninstallError::Join(err) => { - self.send_internal_error( - request_id, - format!("failed to uninstall plugin: {err}"), - ) - .await; - } - CorePluginUninstallError::Store(err) => { - self.send_internal_error( - request_id, - format!("failed to uninstall plugin: {err}"), - ) - .await; - } - CorePluginUninstallError::InvalidPluginId(_) => { - unreachable!("invalid plugin ids are handled above"); - } - } - } - } + }) + .map_err(|err| internal_error(format!("failed to update skill settings: {err}"))) } async fn turn_start( @@ -7127,132 +6313,183 @@ impl CodexMessageProcessor { app_server_client_name: Option, app_server_client_version: Option, ) { - if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { - self.track_error_response( - &request_id, - &error, - Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), - ); - self.outgoing.send_error(request_id, error).await; - return; - } - let (_, thread) = match self.load_thread(¶ms.thread_id).await { - Ok(v) => v, - Err(error) => { - self.track_error_response(&request_id, &error, /*error_type*/ None); - self.outgoing.send_error(request_id, error).await; - return; - } - }; - if let Err(error) = Self::set_app_server_client_info( - thread.as_ref(), - app_server_client_name, - app_server_client_version, - ) - .await - { - self.track_error_response(&request_id, &error, /*error_type*/ None); - self.outgoing.send_error(request_id, error).await; - return; - } - - let collaboration_modes_config = CollaborationModesConfig { - default_mode_request_user_input: thread.enabled(Feature::DefaultModeRequestUserInput), - }; - let collaboration_mode = params.collaboration_mode.map(|mode| { - self.normalize_turn_start_collaboration_mode(mode, collaboration_modes_config) - }); - - // Map v2 input items to core input items. - let mapped_items: Vec = params - .input - .into_iter() - .map(V2UserInput::into_core) - .collect(); - - let has_any_overrides = params.cwd.is_some() - || params.approval_policy.is_some() - || params.approvals_reviewer.is_some() - || params.sandbox_policy.is_some() - || params.model.is_some() - || params.service_tier.is_some() - || params.effort.is_some() - || params.summary.is_some() - || collaboration_mode.is_some() - || params.personality.is_some(); - - // If any overrides are provided, update the session turn context first. - if has_any_overrides { - let _ = self - .submit_core_op( + let result = async { + if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( &request_id, - thread.as_ref(), - Op::OverrideTurnContext { - cwd: params.cwd, - approval_policy: params.approval_policy.map(AskForApproval::to_core), - approvals_reviewer: params - .approvals_reviewer - .map(codex_app_server_protocol::ApprovalsReviewer::to_core), - sandbox_policy: params.sandbox_policy.map(|p| p.to_core()), - windows_sandbox_level: None, - model: params.model, - effort: params.effort.map(Some), - summary: params.summary, - service_tier: params.service_tier, - collaboration_mode, - personality: params.personality, - }, - ) - .await; - } - - // Start the turn by submitting the user input. Return its submission id as turn_id. - let turn_id = self - .submit_core_op( - &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); + return Err(error); + } + let (_, thread) = self + .load_thread(¶ms.thread_id) + .await + .inspect_err(|error| { + self.track_error_response(&request_id, error, /*error_type*/ None); + })?; + Self::set_app_server_client_info( thread.as_ref(), - Op::UserInput { + app_server_client_name, + app_server_client_version, + ) + .await + .inspect_err(|error| { + self.track_error_response(&request_id, error, /*error_type*/ None); + })?; + + let collaboration_modes_config = CollaborationModesConfig { + default_mode_request_user_input: thread + .enabled(Feature::DefaultModeRequestUserInput), + }; + let collaboration_mode = params.collaboration_mode.map(|mode| { + self.normalize_turn_start_collaboration_mode(mode, collaboration_modes_config) + }); + let environments: Option> = + params.environments.map(|environments| { + environments + .into_iter() + .map(|environment| TurnEnvironmentSelection { + environment_id: environment.environment_id, + cwd: environment.cwd, + }) + .collect() + }); + if let Some(environments) = environments.as_ref() { + self.thread_manager + .validate_environment_selections(environments) + .map_err(|err| invalid_request(environment_selection_error_message(err)))?; + } + + // Map v2 input items to core input items. + let mapped_items: Vec = params + .input + .into_iter() + .map(V2UserInput::into_core) + .collect(); + + let has_any_overrides = params.cwd.is_some() + || params.approval_policy.is_some() + || params.approvals_reviewer.is_some() + || params.sandbox_policy.is_some() + || params.permission_profile.is_some() + || params.model.is_some() + || params.service_tier.is_some() + || params.effort.is_some() + || params.summary.is_some() + || collaboration_mode.is_some() + || params.personality.is_some(); + + if params.sandbox_policy.is_some() && params.permission_profile.is_some() { + return Err(invalid_request( + "`permissionProfile` cannot be combined with `sandboxPolicy`", + )); + } + + let cwd = params.cwd; + let approval_policy = params.approval_policy.map(AskForApproval::to_core); + let approvals_reviewer = params + .approvals_reviewer + .map(codex_app_server_protocol::ApprovalsReviewer::to_core); + let sandbox_policy = params.sandbox_policy.map(|p| p.to_core()); + let permission_profile = params.permission_profile.map(Into::into); + let model = params.model; + let effort = params.effort.map(Some); + let summary = params.summary; + let service_tier = params.service_tier; + let personality = params.personality; + + // If any overrides are provided, validate them synchronously so the + // request can fail before accepting user input. The actual update is + // still queued together with the input below to preserve submission order. + if has_any_overrides { + thread + .validate_turn_context_overrides(CodexThreadTurnContextOverrides { + cwd: cwd.clone(), + approval_policy, + approvals_reviewer, + sandbox_policy: sandbox_policy.clone(), + permission_profile: permission_profile.clone(), + windows_sandbox_level: None, + model: model.clone(), + effort, + summary, + service_tier, + collaboration_mode: collaboration_mode.clone(), + personality, + }) + .await + .map_err(|err| { + invalid_request(format!("invalid turn context override: {err}")) + })?; + } + + // Start the turn by submitting the user input. Return its submission id as turn_id. + let turn_op = if has_any_overrides { + Op::UserInputWithTurnContext { items: mapped_items, + environments, final_output_json_schema: params.output_schema, responsesapi_client_metadata: params.responsesapi_client_metadata, - }, - ) - .await; - - match turn_id { - Ok(turn_id) => { - self.outgoing - .record_request_turn_id(&request_id, &turn_id) - .await; - let turn = Turn { - id: turn_id.clone(), - items: vec![], - error: None, - status: TurnStatus::InProgress, - started_at: None, - completed_at: None, - duration_ms: None, - }; - - let response = TurnStartResponse { turn }; - if self.config.features.enabled(Feature::GeneralAnalytics) { - self.analytics_events_client.track_response( - request_id.connection_id.0, - ClientResponse::TurnStart { - request_id: request_id.request_id.clone(), - response: response.clone(), - }, - ); + cwd, + approval_policy, + approvals_reviewer, + sandbox_policy, + permission_profile, + windows_sandbox_level: None, + model, + effort, + summary, + service_tier, + collaboration_mode, + personality, } + } else { + Op::UserInput { + items: mapped_items, + environments, + final_output_json_schema: params.output_schema, + responsesapi_client_metadata: params.responsesapi_client_metadata, + } + }; + let turn_id = self + .submit_core_op(&request_id, thread.as_ref(), turn_op) + .await + .map_err(|err| { + let error = internal_error(format!("failed to start turn: {err}")); + self.track_error_response(&request_id, &error, /*error_type*/ None); + error + })?; + + self.outgoing + .record_request_turn_id(&request_id, &turn_id) + .await; + let turn = Turn { + id: turn_id, + items: vec![], + error: None, + status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, + }; + + Ok::<_, JSONRPCErrorError>(TurnStartResponse { turn }) + } + .await; + + match result { + Ok(response) => { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); self.outgoing.send_response(request_id, response).await; } - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to start turn: {err}"), - data: None, - }; - self.track_error_response(&request_id, &error, /*error_type*/ None); + Err(error) => { self.outgoing.send_error(request_id, error).await; } } @@ -7263,15 +6500,17 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: ThreadInjectItemsParams, ) { - let (_, thread) = match self.load_thread(¶ms.thread_id).await { - Ok(value) => value, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; + let result = self.thread_inject_items_response(params).await; + self.outgoing.send_result(request_id, result).await; + } - let items = match params + async fn thread_inject_items_response( + &self, + params: ThreadInjectItemsParams, + ) -> Result { + let (_, thread) = self.load_thread(¶ms.thread_id).await?; + + let items = params .items .into_iter() .enumerate() @@ -7280,31 +6519,16 @@ impl CodexMessageProcessor { .map_err(|err| format!("items[{index}] is not a valid response item: {err}")) }) .collect::, _>>() - { - Ok(items) => items, - Err(message) => { - self.send_invalid_request_error(request_id, message).await; - return; - } - }; + .map_err(invalid_request)?; - match thread.inject_response_items(items).await { - Ok(()) => { - self.outgoing - .send_response(request_id, ThreadInjectItemsResponse {}) - .await; - } - Err(CodexErr::InvalidRequest(message)) => { - self.send_invalid_request_error(request_id, message).await; - } - Err(err) => { - self.send_internal_error( - request_id, - format!("failed to inject response items: {err}"), - ) - .await; - } - } + thread + .inject_response_items(items) + .await + .map_err(|err| match err { + CodexErr::InvalidRequest(message) => invalid_request(message), + err => internal_error(format!("failed to inject response items: {err}")), + })?; + Ok(ThreadInjectItemsResponse {}) } async fn set_app_server_client_info( @@ -7323,129 +6547,126 @@ impl CodexMessageProcessor { } async fn turn_steer(&self, request_id: ConnectionRequestId, params: TurnSteerParams) { - let (_, thread) = match self.load_thread(¶ms.thread_id).await { - Ok(v) => v, - Err(error) => { - self.track_error_response(&request_id, &error, /*error_type*/ None); - self.outgoing.send_error(request_id, error).await; - return; + let result = async { + let (_, thread) = self + .load_thread(¶ms.thread_id) + .await + .inspect_err(|error| { + self.track_error_response(&request_id, error, /*error_type*/ None); + })?; + + if params.expected_turn_id.is_empty() { + return Err(invalid_request("expectedTurnId must not be empty")); + } + self.outgoing + .record_request_turn_id(&request_id, ¶ms.expected_turn_id) + .await; + if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); + return Err(error); } - }; - if params.expected_turn_id.is_empty() { - self.send_invalid_request_error( - request_id, - "expectedTurnId must not be empty".to_string(), - ) - .await; - return; - } - self.outgoing - .record_request_turn_id(&request_id, ¶ms.expected_turn_id) - .await; - if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { - self.track_error_response( - &request_id, - &error, - Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), - ); - self.outgoing.send_error(request_id, error).await; - return; + let mapped_items: Vec = params + .input + .into_iter() + .map(V2UserInput::into_core) + .collect(); + + let turn_id = thread + .steer_input( + mapped_items, + Some(¶ms.expected_turn_id), + params.responsesapi_client_metadata, + ) + .await + .map_err(|err| { + let (code, message, data, error_type) = match err { + SteerInputError::NoActiveTurn(_) => ( + INVALID_REQUEST_ERROR_CODE, + "no active turn to steer".to_string(), + None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), + ), + SteerInputError::ExpectedTurnMismatch { expected, actual } => ( + INVALID_REQUEST_ERROR_CODE, + format!("expected active turn id `{expected}` but found `{actual}`"), + None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), + ), + SteerInputError::ActiveTurnNotSteerable { turn_kind } => { + let (message, turn_steer_error) = match turn_kind { + codex_protocol::protocol::NonSteerableTurnKind::Review => ( + "cannot steer a review turn".to_string(), + TurnSteerRequestError::NonSteerableReview, + ), + codex_protocol::protocol::NonSteerableTurnKind::Compact => ( + "cannot steer a compact turn".to_string(), + TurnSteerRequestError::NonSteerableCompact, + ), + }; + let error = TurnError { + message: message.clone(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), + additional_details: None, + }; + let data = match serde_json::to_value(error) { + Ok(data) => Some(data), + Err(error) => { + tracing::error!( + ?error, + "failed to serialize active-turn-not-steerable turn error" + ); + None + } + }; + ( + INVALID_REQUEST_ERROR_CODE, + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer(turn_steer_error)), + ) + } + SteerInputError::EmptyInput => ( + INVALID_REQUEST_ERROR_CODE, + "input must not be empty".to_string(), + None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), + ), + }; + let error = JSONRPCErrorError { + code, + message, + data, + }; + self.track_error_response(&request_id, &error, error_type); + error + })?; + Ok::<_, JSONRPCErrorError>(TurnSteerResponse { turn_id }) } + .await; - let mapped_items: Vec = params - .input - .into_iter() - .map(V2UserInput::into_core) - .collect(); - - match thread - .steer_input( - mapped_items, - Some(¶ms.expected_turn_id), - params.responsesapi_client_metadata, - ) - .await - { - Ok(turn_id) => { - let response = TurnSteerResponse { turn_id }; - if self.config.features.enabled(Feature::GeneralAnalytics) { - self.analytics_events_client.track_response( - request_id.connection_id.0, - ClientResponse::TurnSteer { - request_id: request_id.request_id.clone(), - response: response.clone(), - }, - ); - } + match result { + Ok(response) => { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::TurnSteer { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); self.outgoing.send_response(request_id, response).await; } - Err(err) => { - let (code, message, data, error_type) = match err { - SteerInputError::NoActiveTurn(_) => ( - INVALID_REQUEST_ERROR_CODE, - "no active turn to steer".to_string(), - None, - Some(AnalyticsJsonRpcError::TurnSteer( - TurnSteerRequestError::NoActiveTurn, - )), - ), - SteerInputError::ExpectedTurnMismatch { expected, actual } => ( - INVALID_REQUEST_ERROR_CODE, - format!("expected active turn id `{expected}` but found `{actual}`"), - None, - Some(AnalyticsJsonRpcError::TurnSteer( - TurnSteerRequestError::ExpectedTurnMismatch, - )), - ), - SteerInputError::ActiveTurnNotSteerable { turn_kind } => { - let (message, turn_steer_error) = match turn_kind { - codex_protocol::protocol::NonSteerableTurnKind::Review => ( - "cannot steer a review turn".to_string(), - TurnSteerRequestError::NonSteerableReview, - ), - codex_protocol::protocol::NonSteerableTurnKind::Compact => ( - "cannot steer a compact turn".to_string(), - TurnSteerRequestError::NonSteerableCompact, - ), - }; - let error = TurnError { - message: message.clone(), - codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }), - additional_details: None, - }; - let data = match serde_json::to_value(error) { - Ok(data) => Some(data), - Err(error) => { - tracing::error!( - ?error, - "failed to serialize active-turn-not-steerable turn error" - ); - None - } - }; - ( - INVALID_REQUEST_ERROR_CODE, - message, - data, - Some(AnalyticsJsonRpcError::TurnSteer(turn_steer_error)), - ) - } - SteerInputError::EmptyInput => ( - INVALID_REQUEST_ERROR_CODE, - "input must not be empty".to_string(), - None, - Some(AnalyticsJsonRpcError::Input(InputError::Empty)), - ), - }; - let error = JSONRPCErrorError { - code, - message, - data, - }; - self.track_error_response(&request_id, &error, error_type); + Err(error) => { self.outgoing.send_error(request_id, error).await; } } @@ -7453,16 +6674,10 @@ impl CodexMessageProcessor { async fn prepare_realtime_conversation_thread( &self, - request_id: ConnectionRequestId, + request_id: &ConnectionRequestId, thread_id: &str, - ) -> Option<(ThreadId, Arc)> { - let (thread_id, thread) = match self.load_thread(thread_id).await { - Ok(v) => v, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return None; - } - }; + ) -> Result)>, JSONRPCErrorError> { + let (thread_id, thread) = self.load_thread(thread_id).await?; match self .ensure_conversation_listener( @@ -7475,24 +6690,18 @@ impl CodexMessageProcessor { { Ok(EnsureConversationListenerResult::Attached) => {} Ok(EnsureConversationListenerResult::ConnectionClosed) => { - return None; - } - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return None; + return Ok(None); } + Err(error) => return Err(error), } if !thread.enabled(Feature::RealtimeConversation) { - self.send_invalid_request_error( - request_id, - format!("thread {thread_id} does not support realtime conversation"), - ) - .await; - return None; + return Err(invalid_request(format!( + "thread {thread_id} does not support realtime conversation" + ))); } - Some((thread_id, thread)) + Ok(Some((thread_id, thread))) } async fn thread_realtime_start( @@ -7500,15 +6709,14 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: ThreadRealtimeStartParams, ) { - let Some((_, thread)) = self - .prepare_realtime_conversation_thread(request_id.clone(), ¶ms.thread_id) - .await - else { - return; - }; - - let submit = self - .submit_core_op( + let result = async { + let Some((_, thread)) = self + .prepare_realtime_conversation_thread(&request_id, ¶ms.thread_id) + .await? + else { + return Ok(None); + }; + self.submit_core_op( &request_id, thread.as_ref(), Op::RealtimeConversationStart(ConversationStartParams { @@ -7526,22 +6734,14 @@ impl CodexMessageProcessor { voice: params.voice, }), ) - .await; - - match submit { - Ok(_) => { - self.outgoing - .send_response(request_id, ThreadRealtimeStartResponse::default()) - .await; - } - Err(err) => { - self.send_internal_error( - request_id, - format!("failed to start realtime conversation: {err}"), - ) - .await; - } + .await + .map_err(|err| { + internal_error(format!("failed to start realtime conversation: {err}")) + })?; + Ok::<_, JSONRPCErrorError>(Some(ThreadRealtimeStartResponse::default())) } + .await; + self.send_optional_result(request_id, result).await; } async fn thread_realtime_append_audio( @@ -7549,37 +6749,30 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: ThreadRealtimeAppendAudioParams, ) { - let Some((_, thread)) = self - .prepare_realtime_conversation_thread(request_id.clone(), ¶ms.thread_id) - .await - else { - return; - }; - - let submit = self - .submit_core_op( + let result = async { + let Some((_, thread)) = self + .prepare_realtime_conversation_thread(&request_id, ¶ms.thread_id) + .await? + else { + return Ok(None); + }; + self.submit_core_op( &request_id, thread.as_ref(), Op::RealtimeConversationAudio(ConversationAudioParams { frame: params.audio.into(), }), ) - .await; - - match submit { - Ok(_) => { - self.outgoing - .send_response(request_id, ThreadRealtimeAppendAudioResponse::default()) - .await; - } - Err(err) => { - self.send_internal_error( - request_id, - format!("failed to append realtime conversation audio: {err}"), - ) - .await; - } + .await + .map_err(|err| { + internal_error(format!( + "failed to append realtime conversation audio: {err}" + )) + })?; + Ok::<_, JSONRPCErrorError>(Some(ThreadRealtimeAppendAudioResponse::default())) } + .await; + self.send_optional_result(request_id, result).await; } async fn thread_realtime_append_text( @@ -7587,35 +6780,28 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: ThreadRealtimeAppendTextParams, ) { - let Some((_, thread)) = self - .prepare_realtime_conversation_thread(request_id.clone(), ¶ms.thread_id) - .await - else { - return; - }; - - let submit = self - .submit_core_op( + let result = async { + let Some((_, thread)) = self + .prepare_realtime_conversation_thread(&request_id, ¶ms.thread_id) + .await? + else { + return Ok(None); + }; + self.submit_core_op( &request_id, thread.as_ref(), Op::RealtimeConversationText(ConversationTextParams { text: params.text }), ) - .await; - - match submit { - Ok(_) => { - self.outgoing - .send_response(request_id, ThreadRealtimeAppendTextResponse::default()) - .await; - } - Err(err) => { - self.send_internal_error( - request_id, - format!("failed to append realtime conversation text: {err}"), - ) - .await; - } + .await + .map_err(|err| { + internal_error(format!( + "failed to append realtime conversation text: {err}" + )) + })?; + Ok::<_, JSONRPCErrorError>(Some(ThreadRealtimeAppendTextResponse::default())) } + .await; + self.send_optional_result(request_id, result).await; } async fn thread_realtime_stop( @@ -7623,31 +6809,22 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: ThreadRealtimeStopParams, ) { - let Some((_, thread)) = self - .prepare_realtime_conversation_thread(request_id.clone(), ¶ms.thread_id) - .await - else { - return; - }; - - let submit = self - .submit_core_op(&request_id, thread.as_ref(), Op::RealtimeConversationClose) - .await; - - match submit { - Ok(_) => { - self.outgoing - .send_response(request_id, ThreadRealtimeStopResponse::default()) - .await; - } - Err(err) => { - self.send_internal_error( - request_id, - format!("failed to stop realtime conversation: {err}"), - ) - .await; - } + let result = async { + let Some((_, thread)) = self + .prepare_realtime_conversation_thread(&request_id, ¶ms.thread_id) + .await? + else { + return Ok(None); + }; + self.submit_core_op(&request_id, thread.as_ref(), Op::RealtimeConversationClose) + .await + .map_err(|err| { + internal_error(format!("failed to stop realtime conversation: {err}")) + })?; + Ok::<_, JSONRPCErrorError>(Some(ThreadRealtimeStopResponse::default())) } + .await; + self.send_optional_result(request_id, result).await; } async fn thread_realtime_list_voices( @@ -7814,7 +6991,7 @@ impl CodexMessageProcessor { .await, /*has_in_progress_turn*/ false, ); - let notif = ThreadStartedNotification { thread }; + let notif = thread_started_notification(thread); self.outgoing .send_server_notification(ServerNotification::ThreadStarted(notif)) .await; @@ -7861,114 +7038,104 @@ impl CodexMessageProcessor { target, delivery, } = params; - let (parent_thread_id, parent_thread) = match self.load_thread(&thread_id).await { - Ok(v) => v, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; - - let (review_request, display_text) = match Self::review_request_from_target(target) { - Ok(value) => value, - Err(err) => { - self.outgoing.send_error(request_id, err).await; - return; - } - }; - - let delivery = delivery.unwrap_or(ApiReviewDelivery::Inline).to_core(); - match delivery { - CoreReviewDelivery::Inline => { - if let Err(err) = self - .start_inline_review( + let result = async { + let (parent_thread_id, parent_thread) = self.load_thread(&thread_id).await?; + let (review_request, display_text) = Self::review_request_from_target(target)?; + match delivery.unwrap_or(ApiReviewDelivery::Inline).to_core() { + CoreReviewDelivery::Inline => { + self.start_inline_review( &request_id, parent_thread, review_request, display_text.as_str(), - thread_id.clone(), + thread_id, ) - .await - { - self.outgoing.send_error(request_id, err).await; + .await?; } - } - CoreReviewDelivery::Detached => { - if let Err(err) = self - .start_detached_review( + CoreReviewDelivery::Detached => { + self.start_detached_review( &request_id, parent_thread_id, parent_thread, review_request, display_text.as_str(), ) - .await - { - self.outgoing.send_error(request_id, err).await; + .await?; } } + Ok::<_, JSONRPCErrorError>(None::) } + .await; + self.send_optional_result(request_id, result).await; } async fn turn_interrupt(&self, request_id: ConnectionRequestId, params: TurnInterruptParams) { let TurnInterruptParams { thread_id, turn_id } = params; let is_startup_interrupt = turn_id.is_empty(); - if !is_startup_interrupt { - self.outgoing - .record_request_turn_id(&request_id, &turn_id) - .await; - } - let (thread_uuid, thread) = match self.load_thread(&thread_id).await { - Ok(v) => v, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; + let result = async { + let (thread_uuid, thread) = self.load_thread(&thread_id).await?; - // Record turn interrupts so we can reply when TurnAborted arrives. Startup - // interrupts do not have a turn and are acknowledged after submission. - if !is_startup_interrupt { - let thread_state = self.thread_state_manager.thread_state(thread_uuid).await; - let mut thread_state = thread_state.lock().await; - thread_state - .pending_interrupts - .push((request_id.clone(), ApiVersion::V2)); - } - - // Submit the interrupt. Turn interrupts respond upon TurnAborted; startup - // interrupts respond here because startup cancellation has no turn event. - let submit_result = self - .submit_core_op(&request_id, thread.as_ref(), Op::Interrupt) - .await; - match submit_result { - Ok(_) if is_startup_interrupt => { - self.outgoing - .send_response(request_id, TurnInterruptResponse {}) - .await; - } - Ok(_) => {} - Err(err) => { - if !is_startup_interrupt { - let thread_state = self.thread_state_manager.thread_state(thread_uuid).await; + // Record turn interrupts so we can reply when TurnAborted arrives. Startup + // interrupts do not have a turn and are acknowledged after submission. + if !is_startup_interrupt { + let thread_state = self.thread_state_manager.thread_state(thread_uuid).await; + let is_running = matches!(thread.agent_status().await, AgentStatus::Running); + { let mut thread_state = thread_state.lock().await; + if let Some(active_turn) = thread_state.active_turn_snapshot() { + if active_turn.id != turn_id { + return Err(invalid_request(format!( + "expected active turn id {turn_id} but found {}", + active_turn.id + ))); + } + } else if thread_state.last_terminal_turn_id.as_deref() + == Some(turn_id.as_str()) + || !is_running + { + return Err(invalid_request("no active turn to interrupt")); + } thread_state .pending_interrupts - .retain(|(pending_request_id, _)| pending_request_id != &request_id); + .push((request_id.clone(), ApiVersion::V2)); + } + + self.outgoing + .record_request_turn_id(&request_id, &turn_id) + .await; + } + + // Submit the interrupt. Turn interrupts respond upon TurnAborted; startup + // interrupts respond here because startup cancellation has no turn event. + match self + .submit_core_op(&request_id, thread.as_ref(), Op::Interrupt) + .await + { + Ok(_) if is_startup_interrupt => Ok(Some(TurnInterruptResponse {})), + Ok(_) => Ok(None), + Err(err) => { + if !is_startup_interrupt { + let thread_state = + self.thread_state_manager.thread_state(thread_uuid).await; + let mut thread_state = thread_state.lock().await; + thread_state + .pending_interrupts + .retain(|(pending_request_id, _)| pending_request_id != &request_id); + } + let interrupt_target = if is_startup_interrupt { + "startup" + } else { + "turn" + }; + Err(internal_error(format!( + "failed to interrupt {interrupt_target}: {err}" + ))) } - let interrupt_target = if is_startup_interrupt { - "startup" - } else { - "turn" - }; - self.send_internal_error( - request_id, - format!("failed to interrupt {interrupt_target}: {err}"), - ) - .await; } } + .await; + self.send_optional_result(request_id, result).await; } async fn ensure_conversation_listener( @@ -7985,7 +7152,6 @@ impl CodexMessageProcessor { outgoing: Arc::clone(&self.outgoing), pending_thread_unloads: Arc::clone(&self.pending_thread_unloads), analytics_events_client: self.analytics_events_client.clone(), - general_analytics_enabled: self.config.features.enabled(Feature::GeneralAnalytics), thread_watch_manager: self.thread_watch_manager.clone(), fallback_model_provider: self.config.model_provider_id.clone(), codex_home: self.config.codex_home.to_path_buf(), @@ -8103,7 +7269,6 @@ impl CodexMessageProcessor { outgoing: Arc::clone(&self.outgoing), pending_thread_unloads: Arc::clone(&self.pending_thread_unloads), analytics_events_client: self.analytics_events_client.clone(), - general_analytics_enabled: self.config.features.enabled(Feature::GeneralAnalytics), thread_watch_manager: self.thread_watch_manager.clone(), fallback_model_provider: self.config.model_provider_id.clone(), codex_home: self.config.codex_home.to_path_buf(), @@ -8152,7 +7317,6 @@ impl CodexMessageProcessor { thread_state_manager, pending_thread_unloads, analytics_events_client: _, - general_analytics_enabled: _, thread_watch_manager, fallback_model_provider, codex_home, @@ -8197,7 +7361,7 @@ impl CodexMessageProcessor { // opt-in stays synchronized with the conversation. let raw_events_enabled = { let mut thread_state = thread_state.lock().await; - thread_state.track_current_turn_event(&event.msg); + thread_state.track_current_turn_event(&event.id, &event.msg); thread_state.experimental_raw_events }; let subscribed_connection_ids = thread_state_manager @@ -8228,9 +7392,7 @@ impl CodexMessageProcessor { conversation_id, conversation.clone(), thread_manager.clone(), - listener_task_context - .general_analytics_enabled - .then(|| listener_task_context.analytics_events_client.clone()), + Some(listener_task_context.analytics_events_client.clone()), thread_outgoing, thread_state.clone(), thread_watch_manager.clone(), @@ -8284,24 +7446,18 @@ impl CodexMessageProcessor { Ok(()) } async fn git_diff_to_origin(&self, request_id: ConnectionRequestId, cwd: PathBuf) { - let diff = git_diff_to_remote(&cwd).await; - match diff { - Some(value) => { - let response = GitDiffToRemoteResponse { - sha: value.sha, - diff: value.diff, - }; - self.outgoing.send_response(request_id, response).await; - } - None => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("failed to compute git diff to remote for cwd: {cwd:?}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - } + let result = git_diff_to_remote(&cwd) + .await + .map(|value| GitDiffToRemoteResponse { + sha: value.sha, + diff: value.diff, + }) + .ok_or_else(|| { + invalid_request(format!( + "failed to compute git diff to remote for cwd: {cwd:?}" + )) + }); + self.outgoing.send_result(request_id, result).await; } async fn fuzzy_file_search( @@ -8353,38 +7509,29 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: FuzzyFileSearchSessionStartParams, ) { + let result = self.fuzzy_file_search_session_start_response(params).await; + self.outgoing.send_result(request_id, result).await; + } + + async fn fuzzy_file_search_session_start_response( + &self, + params: FuzzyFileSearchSessionStartParams, + ) -> Result { let FuzzyFileSearchSessionStartParams { session_id, roots } = params; if session_id.is_empty() { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "sessionId must not be empty".to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; + return Err(invalid_request("sessionId must not be empty")); } let session = - start_fuzzy_file_search_session(session_id.clone(), roots, self.outgoing.clone()); - match session { - Ok(session) => { - self.fuzzy_search_sessions - .lock() - .await - .insert(session_id, session); - self.outgoing - .send_response(request_id, FuzzyFileSearchSessionStartResponse {}) - .await; - } - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to start fuzzy file search session: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - } + start_fuzzy_file_search_session(session_id.clone(), roots, self.outgoing.clone()) + .map_err(|err| { + internal_error(format!("failed to start fuzzy file search session: {err}")) + })?; + self.fuzzy_search_sessions + .lock() + .await + .insert(session_id, session); + Ok(FuzzyFileSearchSessionStartResponse {}) } async fn fuzzy_file_search_session_update( @@ -8392,6 +7539,14 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: FuzzyFileSearchSessionUpdateParams, ) { + let result = self.fuzzy_file_search_session_update_response(params).await; + self.outgoing.send_result(request_id, result).await; + } + + async fn fuzzy_file_search_session_update_response( + &self, + params: FuzzyFileSearchSessionUpdateParams, + ) -> Result { let FuzzyFileSearchSessionUpdateParams { session_id, query } = params; let found = { let sessions = self.fuzzy_search_sessions.lock().await; @@ -8403,18 +7558,12 @@ impl CodexMessageProcessor { } }; if !found { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("fuzzy file search session not found: {session_id}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; + return Err(invalid_request(format!( + "fuzzy file search session not found: {session_id}" + ))); } - self.outgoing - .send_response(request_id, FuzzyFileSearchSessionUpdateResponse {}) - .await; + Ok(FuzzyFileSearchSessionUpdateResponse {}) } async fn fuzzy_file_search_session_stop( @@ -8434,14 +7583,18 @@ impl CodexMessageProcessor { } async fn upload_feedback(&self, request_id: ConnectionRequestId, params: FeedbackUploadParams) { + let result = self.upload_feedback_response(params).await; + self.outgoing.send_result(request_id, result).await; + } + + async fn upload_feedback_response( + &self, + params: FeedbackUploadParams, + ) -> Result { if !self.config.feedback_enabled { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "sending feedback is disabled by configuration".to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; + return Err(invalid_request( + "sending feedback is disabled by configuration", + )); } let FeedbackUploadParams { @@ -8456,15 +7609,7 @@ impl CodexMessageProcessor { let conversation_id = match thread_id.as_deref() { Some(thread_id) => match ThreadId::from_string(thread_id) { Ok(conversation_id) => Some(conversation_id), - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid thread id: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } + Err(err) => return Err(invalid_request(format!("invalid thread id: {err}"))), }, None => None, }; @@ -8593,30 +7738,14 @@ impl CodexMessageProcessor { let upload_result = match upload_result { Ok(result) => result, Err(join_err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to upload feedback: {join_err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; + return Err(internal_error(format!( + "failed to upload feedback: {join_err}" + ))); } }; - match upload_result { - Ok(()) => { - let response = FeedbackUploadResponse { thread_id }; - self.outgoing.send_response(request_id, response).await; - } - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to upload feedback: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - } + upload_result.map_err(|err| internal_error(format!("failed to upload feedback: {err}")))?; + Ok(FeedbackUploadResponse { thread_id }) } async fn windows_sandbox_setup_start( @@ -8659,7 +7788,9 @@ impl CodexMessageProcessor { Ok(config) => { let setup_request = WindowsSandboxSetupRequest { mode, - policy: config.permissions.sandbox_policy.get().clone(), + policy: config + .permissions + .legacy_sandbox_policy(config.cwd.as_path()), policy_cwd: config.cwd.to_path_buf(), command_cwd, env_map: std::env::vars().collect(), @@ -8707,27 +7838,58 @@ impl CodexMessageProcessor { None }) } + + async fn send_invalid_request_error( + &self, + request_id: ConnectionRequestId, + message: impl Into, + ) { + self.outgoing + .send_error(request_id, invalid_request(message)) + .await; + } + + async fn send_internal_error( + &self, + request_id: ConnectionRequestId, + message: impl Into, + ) { + self.outgoing + .send_error(request_id, internal_error(message)) + .await; + } } -fn normalize_thread_list_cwd_filter( - cwd: Option, -) -> Result, JSONRPCErrorError> { +fn normalize_thread_list_cwd_filters( + cwd: Option, +) -> Result>, JSONRPCErrorError> { let Some(cwd) = cwd else { return Ok(None); }; - AbsolutePathBuf::relative_to_current_dir(cwd.as_str()) - .map(AbsolutePathBuf::into_path_buf) - .map(Some) - .map_err(|err| JSONRPCErrorError { - code: INVALID_PARAMS_ERROR_CODE, - message: format!("invalid thread/list cwd filter `{cwd}`: {err}"), - data: None, - }) + + let cwds = match cwd { + ThreadListCwdFilter::One(cwd) => vec![cwd], + ThreadListCwdFilter::Many(cwds) => cwds, + }; + let mut normalized_cwds = Vec::with_capacity(cwds.len()); + for cwd in cwds { + let cwd = AbsolutePathBuf::relative_to_current_dir(cwd.as_str()) + .map(AbsolutePathBuf::into_path_buf) + .map_err(|err| JSONRPCErrorError { + code: INVALID_PARAMS_ERROR_CODE, + message: format!("invalid thread/list cwd filter `{cwd}`: {err}"), + data: None, + })?; + normalized_cwds.push(cwd); + } + + Ok(Some(normalized_cwds)) } #[cfg(test)] mod thread_list_cwd_filter_tests { - use super::normalize_thread_list_cwd_filter; + use super::normalize_thread_list_cwd_filters; + use codex_app_server_protocol::ThreadListCwdFilter; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -8741,8 +7903,9 @@ mod thread_list_cwd_filter_tests { }; assert_eq!( - normalize_thread_list_cwd_filter(Some(cwd.clone())).expect("cwd filter should parse"), - Some(PathBuf::from(cwd)) + normalize_thread_list_cwd_filters(Some(ThreadListCwdFilter::One(cwd.clone()))) + .expect("cwd filter should parse"), + Some(vec![PathBuf::from(cwd)]) ); } @@ -8752,9 +7915,11 @@ mod thread_list_cwd_filter_tests { let expected = AbsolutePathBuf::relative_to_current_dir("repo-b")?.to_path_buf(); assert_eq!( - normalize_thread_list_cwd_filter(Some(String::from("repo-b"))) - .expect("cwd filter should parse"), - Some(expected) + normalize_thread_list_cwd_filters(Some(ThreadListCwdFilter::Many(vec![String::from( + "repo-b" + ),]))) + .expect("cwd filter should parse"), + Some(vec![expected]) ); Ok(()) } @@ -8787,6 +7952,29 @@ async fn handle_thread_listener_command( ) .await; } + ThreadListenerCommand::EmitThreadGoalUpdated { goal } => { + outgoing + .send_server_notification(ServerNotification::ThreadGoalUpdated( + ThreadGoalUpdatedNotification { + thread_id: conversation_id.to_string(), + turn_id: None, + goal, + }, + )) + .await; + } + ThreadListenerCommand::EmitThreadGoalCleared => { + outgoing + .send_server_notification(ServerNotification::ThreadGoalCleared( + ThreadGoalClearedNotification { + thread_id: conversation_id.to_string(), + }, + )) + .await; + } + ThreadListenerCommand::EmitThreadGoalSnapshot { state_db } => { + send_thread_goal_snapshot_notification(outgoing, conversation_id, &state_db).await; + } ThreadListenerCommand::ResolveServerRequest { request_id, completion_tx, @@ -8840,22 +8028,16 @@ async fn handle_pending_thread_resume_request( let request_id = pending.request_id; let connection_id = request_id.connection_id; let mut thread = pending.thread_summary; - if let Err(message) = populate_thread_turns( - &mut thread, - ThreadTurnSource::RolloutPath(pending.rollout_path.as_path()), - active_turn.as_ref(), - ) - .await + if pending.include_turns + && let Err(message) = populate_thread_turns( + &mut thread, + ThreadTurnSource::HistoryItems(&pending.history_items), + active_turn.as_ref(), + ) + .await { outgoing - .send_error( - request_id, - JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message, - data: None, - }, - ) + .send_error(request_id, internal_error(message)) .await; return; } @@ -8877,13 +8059,9 @@ async fn handle_pending_thread_resume_request( outgoing .send_error( request_id, - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!( - "thread {conversation_id} is closing; retry thread/resume after the thread is closed" - ), - data: None, - }, + invalid_request(format!( + "thread {conversation_id} is closing; retry thread/resume after the thread is closed" + )), ) .await; return; @@ -8901,6 +8079,12 @@ async fn handle_pending_thread_resume_request( } } + if pending.emit_thread_goal_update + && let Err(err) = conversation.apply_goal_resume_runtime_effects().await + { + tracing::warn!("failed to apply goal resume runtime effects: {err}"); + } + let ThreadConfigSnapshot { model, model_provider_id, @@ -8908,11 +8092,14 @@ async fn handle_pending_thread_resume_request( approval_policy, approvals_reviewer, sandbox_policy, + permission_profile, cwd, reasoning_effort, .. } = pending.config_snapshot; let instruction_sources = pending.instruction_sources; + let permission_profile = thread_response_permission_profile(permission_profile); + let response = ThreadResumeResponse { thread, model, @@ -8923,33 +8110,88 @@ async fn handle_pending_thread_resume_request( approval_policy: approval_policy.into(), approvals_reviewer: approvals_reviewer.into(), sandbox: sandbox_policy.into(), + permission_profile, reasoning_effort, }; - let token_usage_thread = response.thread.clone(); - let token_usage_turn_id = latest_token_usage_turn_id_from_rollout_path( - pending.rollout_path.as_path(), - &token_usage_thread, - ) - .await; + let token_usage_thread = pending.include_turns.then(|| response.thread.clone()); outgoing.send_response(request_id, response).await; - // Rejoining a loaded thread has the same UI contract as a cold resume, but - // uses the live conversation state instead of reconstructing a new session. - send_thread_token_usage_update_to_connection( - outgoing, - connection_id, - conversation_id, - &token_usage_thread, - conversation.as_ref(), - token_usage_turn_id, - ) - .await; + // Match cold resume: metadata-only resume should attach the listener without + // paying the cost of turn reconstruction for historical usage replay. + if let Some(token_usage_thread) = token_usage_thread { + let token_usage_turn_id = latest_token_usage_turn_id_from_rollout_items( + &pending.history_items, + token_usage_thread.turns.as_slice(), + ); + // Rejoining a loaded thread has the same UI contract as a cold resume, but + // uses the live conversation state instead of reconstructing a new session. + send_thread_token_usage_update_to_connection( + outgoing, + connection_id, + conversation_id, + &token_usage_thread, + conversation.as_ref(), + token_usage_turn_id, + ) + .await; + } + if pending.emit_thread_goal_update { + if let Some(state_db) = pending.thread_goal_state_db { + send_thread_goal_snapshot_notification(outgoing, conversation_id, &state_db).await; + } else { + tracing::warn!( + thread_id = %conversation_id, + "state db unavailable when reading thread goal for running thread resume" + ); + } + } outgoing .replay_requests_to_connection_for_thread(connection_id, conversation_id) .await; + // App-server owns resume response and snapshot ordering, so wait until + // replay completes before letting core start goal continuation. + if pending.emit_thread_goal_update + && let Err(err) = conversation.continue_active_goal_if_idle().await + { + tracing::warn!("failed to continue active goal after running-thread resume: {err}"); + } +} + +async fn send_thread_goal_snapshot_notification( + outgoing: &Arc, + thread_id: ThreadId, + state_db: &StateDbHandle, +) { + match state_db.get_thread_goal(thread_id).await { + Ok(Some(goal)) => { + outgoing + .send_server_notification(ServerNotification::ThreadGoalUpdated( + ThreadGoalUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: None, + goal: api_thread_goal_from_state(goal), + }, + )) + .await; + } + Ok(None) => { + outgoing + .send_server_notification(ServerNotification::ThreadGoalCleared( + ThreadGoalClearedNotification { + thread_id: thread_id.to_string(), + }, + )) + .await; + } + Err(err) => { + tracing::warn!( + thread_id = %thread_id, + "failed to read thread goal for resume snapshot: {err}" + ); + } + } } enum ThreadTurnSource<'a> { - RolloutPath(&'a Path), HistoryItems(&'a [RolloutItem]), } @@ -8959,18 +8201,6 @@ async fn populate_thread_turns( active_turn: Option<&Turn>, ) -> std::result::Result<(), String> { let mut turns = match turn_source { - ThreadTurnSource::RolloutPath(rollout_path) => { - read_rollout_items_from_rollout(rollout_path) - .await - .map(|items| build_turns_from_rollout_items(&items)) - .map_err(|err| { - format!( - "failed to load rollout `{}` for thread {}: {err}", - rollout_path.display(), - thread.id - ) - })? - } ThreadTurnSource::HistoryItems(items) => build_turns_from_rollout_items(items), }; if let Some(active_turn) = active_turn { @@ -9107,6 +8337,16 @@ fn collect_resume_override_mismatches( )); } } + if let Some(requested_permission_profile) = request.permission_profile.as_ref() { + let requested_permission_profile = + codex_protocol::models::PermissionProfile::from(requested_permission_profile.clone()); + if requested_permission_profile != config_snapshot.permission_profile { + mismatch_details.push(format!( + "permission_profile requested={requested_permission_profile:?} active={:?}", + config_snapshot.permission_profile + )); + } + } if let Some(requested_personality) = request.personality.as_ref() && config_snapshot.personality.as_ref() != Some(requested_personality) { @@ -9148,6 +8388,7 @@ fn merge_persisted_resume_metadata( } typesafe_overrides.model = persisted_metadata.model.clone(); + typesafe_overrides.model_provider = Some(persisted_metadata.model_provider.clone()); if let Some(reasoning_effort) = persisted_metadata.reasoning_effort { request_overrides.get_or_insert_with(HashMap::new).insert( @@ -9234,7 +8475,7 @@ fn plugin_skills_to_info( default_prompt: interface.default_prompt, } }), - path: skill.path_to_skills_md.clone(), + path: Some(skill.path_to_skills_md.clone()), enabled: !disabled_skill_paths.contains(&skill.path_to_skills_md), }) .collect() @@ -9384,36 +8625,6 @@ fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> { Ok(()) } -async fn read_history_cwd_from_state_db( - config: &Config, - thread_id: Option, - rollout_path: &Path, -) -> Option { - if let Some(state_db_ctx) = get_state_db(config).await - && let Some(thread_id) = thread_id - && let Ok(Some(metadata)) = state_db_ctx.get_thread(thread_id).await - { - return Some(metadata.cwd); - } - - match read_session_meta_line(rollout_path).await { - Ok(meta_line) => Some(meta_line.meta.cwd), - Err(err) => { - let rollout_path = rollout_path.display(); - warn!("failed to read session metadata from rollout {rollout_path}: {err}"); - None - } - } -} - -async fn read_summary_from_state_db_by_thread_id( - config: &Config, - thread_id: ThreadId, -) -> Option { - let state_db_ctx = open_state_db_for_direct_thread_lookup(config).await; - read_summary_from_state_db_context_by_thread_id(state_db_ctx.as_ref(), thread_id).await -} - async fn read_summary_from_state_db_context_by_thread_id( state_db_ctx: Option<&StateDbHandle>, thread_id: ThreadId, @@ -9471,6 +8682,27 @@ async fn open_state_db_for_direct_thread_lookup(config: &Config) -> Option) -> JSONRPCErrorError { + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: message.into(), + data: None, + } +} + +fn internal_error(message: impl Into) -> JSONRPCErrorError { + JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: message.into(), + data: None, + } +} + +fn parse_thread_id_for_request(thread_id: &str) -> Result { + ThreadId::from_string(thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}"))) +} + fn non_empty_title(metadata: &ThreadMetadata) -> Option { let title = metadata.title.trim(); (!title.is_empty()).then(|| title.to_string()) @@ -9507,6 +8739,26 @@ fn thread_store_list_error(err: ThreadStoreError) -> JSONRPCErrorError { } } +fn thread_store_resume_read_error(err: ThreadStoreError) -> JSONRPCErrorError { + match err { + ThreadStoreError::InvalidRequest { message } => JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message, + data: None, + }, + ThreadStoreError::ThreadNotFound { thread_id } => JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("no rollout found for thread id {thread_id}"), + data: None, + }, + err => JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to read thread: {err}"), + data: None, + }, + } +} + fn conversation_summary_thread_id_read_error( conversation_id: ThreadId, err: ThreadStoreError, @@ -9926,45 +9178,6 @@ fn map_git_info(git_info: &CoreGitInfo) -> ConversationGitInfo { } } -async fn load_thread_summary_for_rollout( - config: &Config, - thread_id: ThreadId, - rollout_path: &Path, - fallback_provider: &str, - persisted_metadata: Option<&ThreadMetadata>, -) -> std::result::Result { - let mut thread = read_summary_from_rollout(rollout_path, fallback_provider) - .await - .map(|summary| summary_to_thread(summary, &config.cwd)) - .map_err(|err| { - format!( - "failed to load rollout `{}` for thread {thread_id}: {err}", - rollout_path.display() - ) - })?; - thread.forked_from_id = forked_from_id_from_rollout(rollout_path).await; - if let Some(persisted_metadata) = persisted_metadata { - merge_mutable_thread_metadata( - &mut thread, - summary_to_thread( - summary_from_thread_metadata(persisted_metadata), - &config.cwd, - ), - ); - } else if let Some(summary) = read_summary_from_state_db_by_thread_id(config, thread_id).await { - merge_mutable_thread_metadata(&mut thread, summary_to_thread(summary, &config.cwd)); - } - let title = if let Some(metadata) = persisted_metadata { - non_empty_title(metadata) - } else { - title_from_state_db(config, thread_id).await - }; - if let Some(title) = title { - set_thread_name_from_title(&mut thread, title); - } - Ok(thread) -} - async fn forked_from_id_from_rollout(path: &Path) -> Option { read_session_meta_line(path) .await @@ -9973,10 +9186,6 @@ async fn forked_from_id_from_rollout(path: &Path) -> Option { .map(|thread_id| thread_id.to_string()) } -fn merge_mutable_thread_metadata(thread: &mut Thread, persisted_thread: Thread) { - thread.git_info = persisted_thread.git_info; -} - fn preview_from_rollout_items(items: &[RolloutItem]) -> String { items .iter() @@ -10025,6 +9234,42 @@ fn with_thread_spawn_agent_metadata( } } +fn thread_response_permission_profile( + permission_profile: codex_protocol::models::PermissionProfile, +) -> Option { + Some(permission_profile.into()) +} + +fn requested_permissions_trust_project(overrides: &ConfigOverrides, cwd: &Path) -> bool { + if matches!( + overrides.sandbox_mode, + Some( + codex_protocol::config_types::SandboxMode::WorkspaceWrite + | codex_protocol::config_types::SandboxMode::DangerFullAccess + ) + ) { + return true; + } + + overrides + .permission_profile + .as_ref() + .is_some_and(|profile| permission_profile_trusts_project(profile, cwd)) +} + +fn permission_profile_trusts_project( + profile: &codex_protocol::models::PermissionProfile, + cwd: &Path, +) -> bool { + match profile { + codex_protocol::models::PermissionProfile::Disabled + | codex_protocol::models::PermissionProfile::External { .. } => true, + codex_protocol::models::PermissionProfile::Managed { .. } => profile + .file_system_sandbox_policy() + .can_write_path_with_cwd(cwd, cwd), + } +} + fn parse_datetime(timestamp: Option<&str>) -> Option> { timestamp.and_then(|ts| { chrono::DateTime::parse_from_rfc3339(ts) @@ -10072,6 +9317,11 @@ fn build_thread_from_snapshot( } } +fn thread_started_notification(mut thread: Thread) -> ThreadStartedNotification { + thread.turns.clear(); + ThreadStartedNotification { thread } +} + pub(crate) fn summary_to_thread( summary: ConversationSummary, fallback_cwd: &AbsolutePathBuf, @@ -10302,15 +9552,19 @@ mod tests { use chrono::Utc; use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::ToolRequestUserInputParams; + use codex_config::CloudRequirementsLoader; + use codex_config::LoaderOverrides; use codex_config::SessionThreadConfig; use codex_config::StaticThreadConfigLoader; use codex_config::ThreadConfigSource; - use codex_core::config_loader::CloudRequirementsLoader; - use codex_core::config_loader::LoaderOverrides; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::WireApi; use codex_protocol::ThreadId; use codex_protocol::openai_models::ReasoningEffort; + use codex_protocol::permissions::FileSystemAccessMode; + use codex_protocol::permissions::FileSystemPath; + use codex_protocol::permissions::FileSystemSandboxEntry; + use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; @@ -10513,6 +9767,106 @@ mod tests { ); } + #[test] + fn thread_response_permission_profile_preserves_enforcement() { + let full_access_profile = codex_protocol::models::PermissionProfile::Disabled; + let external_profile = codex_protocol::models::PermissionProfile::External { + network: codex_protocol::permissions::NetworkSandboxPolicy::Restricted, + }; + + assert_eq!( + thread_response_permission_profile(external_profile.clone()), + Some(external_profile.into()) + ); + assert_eq!( + thread_response_permission_profile(full_access_profile.clone()), + Some(full_access_profile.into()) + ); + } + + #[test] + fn requested_permissions_trust_project_uses_permission_profile_intent() { + let cwd = test_path_buf("/tmp/project").abs(); + let full_access_profile = codex_protocol::models::PermissionProfile::Disabled; + let workspace_write_profile = codex_protocol::models::PermissionProfile::workspace_write(); + let read_only_profile = codex_protocol::models::PermissionProfile::read_only(); + let split_write_profile = + codex_protocol::models::PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: cwd.clone() }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "/tmp/project/**/*.env".to_string(), + }, + access: FileSystemAccessMode::None, + }, + ]), + NetworkSandboxPolicy::Restricted, + ); + + assert!(requested_permissions_trust_project( + &ConfigOverrides { + permission_profile: Some(full_access_profile), + ..Default::default() + }, + cwd.as_path() + )); + assert!(requested_permissions_trust_project( + &ConfigOverrides { + permission_profile: Some(workspace_write_profile), + ..Default::default() + }, + cwd.as_path() + )); + assert!(requested_permissions_trust_project( + &ConfigOverrides { + permission_profile: Some(split_write_profile), + ..Default::default() + }, + cwd.as_path() + )); + assert!(!requested_permissions_trust_project( + &ConfigOverrides { + permission_profile: Some(read_only_profile), + ..Default::default() + }, + cwd.as_path() + )); + } + + #[test] + fn command_profile_preserves_configured_deny_read_restrictions() { + let readable_entry = FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: test_path_buf("/tmp/project").abs(), + }, + access: FileSystemAccessMode::Read, + }; + let deny_entry = FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "/tmp/project/**/*.env".to_string(), + }, + access: FileSystemAccessMode::None, + }; + let mut file_system_sandbox_policy = + FileSystemSandboxPolicy::restricted(vec![readable_entry.clone()]); + let mut configured_file_system_sandbox_policy = + FileSystemSandboxPolicy::restricted(vec![deny_entry.clone()]); + configured_file_system_sandbox_policy.glob_scan_max_depth = Some(2); + + CodexMessageProcessor::preserve_configured_deny_read_restrictions( + &mut file_system_sandbox_policy, + &configured_file_system_sandbox_policy, + ); + + let mut expected = FileSystemSandboxPolicy::restricted(vec![readable_entry, deny_entry]); + expected.glob_scan_max_depth = Some(2); + assert_eq!(file_system_sandbox_policy, expected); + } + #[test] fn config_load_error_marks_cloud_requirements_failures_for_relogin() { let err = std::io::Error::other(CloudRequirementsLoadError::new( @@ -10559,7 +9913,7 @@ mod tests { let err = std::io::Error::other(CloudRequirementsLoadError::new( CloudRequirementsLoadErrorCode::RequestFailed, /*status_code*/ None, - "failed to load your workspace-managed config", + "Failed to load cloud requirements (workspace-managed policies).", )); let error = config_load_error(&err); @@ -10569,7 +9923,7 @@ mod tests { Some(json!({ "reason": "cloudRequirements", "errorCode": "RequestFailed", - "detail": "failed to load your workspace-managed config", + "detail": "Failed to load cloud requirements (workspace-managed policies).", })) ); } @@ -10639,6 +9993,7 @@ mod tests { #[test] fn collect_resume_override_mismatches_includes_service_tier() { + let cwd = test_path_buf("/tmp").abs(); let request = ThreadResumeParams { thread_id: "thread-1".to_string(), history: None, @@ -10650,10 +10005,12 @@ mod tests { approval_policy: None, approvals_reviewer: None, sandbox: None, + permission_profile: None, config: None, base_instructions: None, developer_instructions: None, personality: None, + exclude_turns: false, persist_extended_history: false, }; let config_snapshot = ThreadConfigSnapshot { @@ -10663,7 +10020,8 @@ mod tests { approval_policy: codex_protocol::protocol::AskForApproval::OnRequest, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, - cwd: test_path_buf("/tmp").abs(), + permission_profile: codex_protocol::models::PermissionProfile::Disabled, + cwd, ephemeral: false, reasoning_effort: None, personality: None, @@ -10728,6 +10086,10 @@ mod tests { typesafe_overrides.model, Some("gpt-5.1-codex-max".to_string()) ); + assert_eq!( + typesafe_overrides.model_provider, + Some("mock_provider".to_string()) + ); assert_eq!( request_overrides, Some(HashMap::from([( @@ -10758,6 +10120,7 @@ mod tests { ); assert_eq!(typesafe_overrides.model, Some("gpt-5.2-codex".to_string())); + assert_eq!(typesafe_overrides.model_provider, None); assert_eq!( request_overrides, Some(HashMap::from([( @@ -10786,6 +10149,7 @@ mod tests { ); assert_eq!(typesafe_overrides.model, None); + assert_eq!(typesafe_overrides.model_provider, None); assert_eq!( request_overrides, Some(HashMap::from([( @@ -10837,6 +10201,7 @@ mod tests { ); assert_eq!(typesafe_overrides.model, None); + assert_eq!(typesafe_overrides.model_provider, None); assert_eq!( request_overrides, Some(HashMap::from([( @@ -10861,6 +10226,10 @@ mod tests { ); assert_eq!(typesafe_overrides.model, None); + assert_eq!( + typesafe_overrides.model_provider, + Some("mock_provider".to_string()) + ); assert_eq!(request_overrides, None); Ok(()) } @@ -11184,14 +10553,15 @@ mod tests { let state = manager.thread_state(thread_id).await; let mut state = state.lock().await; state.cancel_tx = Some(cancel_tx); - state.track_current_turn_event(&EventMsg::TurnStarted( - codex_protocol::protocol::TurnStartedEvent { + state.track_current_turn_event( + "turn-1", + &EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), - }, - )); + }), + ); } manager.remove_thread_state(thread_id).await; diff --git a/codex-rs/app-server/src/codex_message_processor/plugins.rs b/codex-rs/app-server/src/codex_message_processor/plugins.rs new file mode 100644 index 0000000000..e1d0fffad3 --- /dev/null +++ b/codex-rs/app-server/src/codex_message_processor/plugins.rs @@ -0,0 +1,710 @@ +use super::*; +use crate::error_code::internal_error; +use crate::error_code::invalid_request; +use codex_app_server_protocol::PluginInstallPolicy; + +impl CodexMessageProcessor { + pub(super) async fn plugin_list( + &self, + request_id: ConnectionRequestId, + params: PluginListParams, + ) { + let result = self.plugin_list_response(params).await; + self.outgoing.send_result(request_id, result).await; + } + + async fn plugin_list_response( + &self, + params: PluginListParams, + ) -> Result { + let plugins_manager = self.thread_manager.plugins_manager(); + let PluginListParams { cwds } = params; + let roots = cwds.unwrap_or_default(); + + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let empty_response = || PluginListResponse { + marketplaces: Vec::new(), + marketplace_load_errors: Vec::new(), + featured_plugin_ids: Vec::new(), + }; + if !config.features.enabled(Feature::Plugins) { + return Ok(empty_response()); + } + let auth = self.auth_manager.auth().await; + if !self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await + { + return Ok(empty_response()); + } + plugins_manager.maybe_start_non_curated_plugin_cache_refresh(&roots); + + let config_for_marketplace_listing = config.clone(); + let plugins_manager_for_marketplace_listing = plugins_manager.clone(); + let (mut data, marketplace_load_errors) = match tokio::task::spawn_blocking(move || { + let outcome = plugins_manager_for_marketplace_listing + .list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?; + Ok::< + ( + Vec, + Vec, + ), + MarketplaceError, + >(( + outcome + .marketplaces + .into_iter() + .map(|marketplace| PluginMarketplaceEntry { + name: marketplace.name, + path: Some(marketplace.path), + interface: marketplace.interface.map(|interface| MarketplaceInterface { + display_name: interface.display_name, + }), + plugins: marketplace + .plugins + .into_iter() + .map(|plugin| PluginSummary { + id: plugin.id, + installed: plugin.installed, + enabled: plugin.enabled, + name: plugin.name, + source: marketplace_plugin_source_to_info(plugin.source), + install_policy: plugin.policy.installation.into(), + auth_policy: plugin.policy.authentication.into(), + interface: plugin.interface.map(local_plugin_interface_to_info), + }) + .collect(), + }) + .collect(), + outcome + .errors + .into_iter() + .map(|err| codex_app_server_protocol::MarketplaceLoadErrorInfo { + marketplace_path: err.path, + message: err.message, + }) + .collect(), + )) + }) + .await + { + Ok(Ok(outcome)) => outcome, + Ok(Err(err)) => return Err(Self::marketplace_error(err, "list marketplace plugins")), + Err(err) => { + return Err(internal_error(format!( + "failed to list marketplace plugins: {err}" + ))); + } + }; + + if config.features.enabled(Feature::RemotePlugin) { + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + match codex_core_plugins::remote::fetch_remote_marketplaces( + &remote_plugin_service_config, + auth.as_ref(), + ) + .await + { + Ok(remote_marketplaces) => { + for remote_marketplace in remote_marketplaces + .into_iter() + .map(remote_marketplace_to_info) + { + if let Some(existing) = data + .iter_mut() + .find(|marketplace| marketplace.name == remote_marketplace.name) + { + *existing = remote_marketplace; + } else { + data.push(remote_marketplace); + } + } + } + Err( + RemotePluginCatalogError::AuthRequired + | RemotePluginCatalogError::UnsupportedAuthMode, + ) => {} + Err(err) => { + warn!( + error = %err, + "plugin/list remote plugin catalog fetch failed; returning local marketplaces only" + ); + } + } + } + + let featured_plugin_ids = if data + .iter() + .any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) + { + match plugins_manager + .featured_plugin_ids_for_config(&config, auth.as_ref()) + .await + { + Ok(featured_plugin_ids) => featured_plugin_ids, + Err(err) => { + warn!( + error = %err, + "plugin/list featured plugin fetch failed; returning empty featured ids" + ); + Vec::new() + } + } + } else { + Vec::new() + }; + + Ok(PluginListResponse { + marketplaces: data, + marketplace_load_errors, + featured_plugin_ids, + }) + } + + pub(super) async fn plugin_read( + &self, + request_id: ConnectionRequestId, + params: PluginReadParams, + ) { + let result = self.plugin_read_response(params).await; + self.outgoing.send_result(request_id, result).await; + } + + async fn plugin_read_response( + &self, + params: PluginReadParams, + ) -> Result { + let plugins_manager = self.thread_manager.plugins_manager(); + let PluginReadParams { + marketplace_path, + remote_marketplace_name, + plugin_name, + } = params; + let read_source = match (marketplace_path, remote_marketplace_name) { + (Some(marketplace_path), None) => Ok(marketplace_path), + (None, Some(remote_marketplace_name)) => Err(remote_marketplace_name), + (Some(_), Some(_)) | (None, None) => { + return Err(invalid_request( + "plugin/read requires exactly one of marketplacePath or remoteMarketplaceName", + )); + } + }; + let config_cwd = read_source.as_ref().ok().and_then(|marketplace_path| { + marketplace_path.as_path().parent().map(Path::to_path_buf) + }); + + let config = self.load_latest_config(config_cwd).await?; + + let plugin = match read_source { + Ok(marketplace_path) => { + let request = PluginReadRequest { + plugin_name, + marketplace_path, + }; + let outcome = plugins_manager + .read_plugin_for_config(&config, &request) + .await + .map_err(|err| Self::marketplace_error(err, "read plugin details"))?; + let environment_manager = self.thread_manager.environment_manager(); + let app_summaries = plugin_app_helpers::load_plugin_app_summaries( + &config, + &outcome.plugin.apps, + &environment_manager, + ) + .await; + let visible_skills = outcome + .plugin + .skills + .iter() + .filter(|skill| { + skill.matches_product_restriction_for_product( + self.thread_manager.session_source().restriction_product(), + ) + }) + .cloned() + .collect::>(); + PluginDetail { + marketplace_name: outcome.marketplace_name, + marketplace_path: outcome.marketplace_path, + summary: PluginSummary { + id: outcome.plugin.id, + name: outcome.plugin.name, + source: marketplace_plugin_source_to_info(outcome.plugin.source), + installed: outcome.plugin.installed, + enabled: outcome.plugin.enabled, + install_policy: outcome.plugin.policy.installation.into(), + auth_policy: outcome.plugin.policy.authentication.into(), + interface: outcome.plugin.interface.map(local_plugin_interface_to_info), + }, + description: outcome.plugin.description, + skills: plugin_skills_to_info( + &visible_skills, + &outcome.plugin.disabled_skill_paths, + ), + apps: app_summaries, + mcp_servers: outcome.plugin.mcp_server_names, + } + } + Err(remote_marketplace_name) => { + if !config.features.enabled(Feature::Plugins) + || !config.features.enabled(Feature::RemotePlugin) + { + return Err(invalid_request(format!( + "remote plugin read is not enabled for marketplace {remote_marketplace_name}" + ))); + } + let auth = self.auth_manager.auth().await; + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + if plugin_name.is_empty() + || !plugin_name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '~') + { + return Err(invalid_request( + "invalid remote plugin id: only ASCII letters, digits, `_`, `-`, and `~` are allowed", + )); + } + let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail( + &remote_plugin_service_config, + auth.as_ref(), + &remote_marketplace_name, + &plugin_name, + ) + .await + .map_err(|err| { + remote_plugin_catalog_error_to_jsonrpc(err, "read remote plugin details") + })?; + let plugin_apps = remote_detail + .app_ids + .iter() + .cloned() + .map(codex_core::plugins::AppConnectorId) + .collect::>(); + let environment_manager = self.thread_manager.environment_manager(); + let app_summaries = plugin_app_helpers::load_plugin_app_summaries( + &config, + &plugin_apps, + &environment_manager, + ) + .await; + remote_plugin_detail_to_info(remote_detail, app_summaries) + } + }; + + Ok(PluginReadResponse { plugin }) + } + + pub(super) async fn plugin_install( + &self, + request_id: ConnectionRequestId, + params: PluginInstallParams, + ) { + let result = self.plugin_install_response(params).await; + self.outgoing.send_result(request_id, result).await; + } + + async fn plugin_install_response( + &self, + params: PluginInstallParams, + ) -> Result { + let PluginInstallParams { + marketplace_path, + remote_marketplace_name, + plugin_name, + } = params; + let marketplace_path = match (marketplace_path, remote_marketplace_name) { + (Some(marketplace_path), None) => marketplace_path, + (None, Some(remote_marketplace_name)) => { + return self + .remote_plugin_install_response(remote_marketplace_name, plugin_name) + .await; + } + (Some(_), Some(_)) | (None, None) => { + return Err(invalid_request( + "plugin/install requires exactly one of marketplacePath or remoteMarketplaceName", + )); + } + }; + let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf); + let config = self.load_latest_config(config_cwd.clone()).await?; + let auth = self.auth_manager.auth().await; + + if !self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await + { + return Err(invalid_request( + "Codex plugins are disabled for this workspace", + )); + } + + let plugins_manager = self.thread_manager.plugins_manager(); + let request = PluginInstallRequest { + plugin_name, + marketplace_path, + }; + + let result = plugins_manager + .install_plugin(request) + .await + .map_err(Self::plugin_install_error)?; + let config = match self.load_latest_config(config_cwd).await { + Ok(config) => config, + Err(err) => { + warn!( + "failed to reload config after plugin install, using current config: {err:?}" + ); + config + } + }; + + self.clear_plugin_related_caches(); + + let plugin_mcp_servers = load_plugin_mcp_servers(result.installed_path.as_path()).await; + + if !plugin_mcp_servers.is_empty() { + if let Err(err) = self.queue_mcp_server_refresh_for_config(&config).await { + warn!( + plugin = result.plugin_id.as_key(), + "failed to queue MCP refresh after plugin install: {err:?}" + ); + } + self.start_plugin_mcp_oauth_logins(&config, plugin_mcp_servers) + .await; + } + + let plugin_apps = load_plugin_apps(result.installed_path.as_path()).await; + let auth = self.auth_manager.auth().await; + let apps_needing_auth = self + .plugin_apps_needing_auth_for_install( + &config, + auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth), + &result.plugin_id.as_key(), + &plugin_apps, + ) + .await; + + Ok(PluginInstallResponse { + auth_policy: result.auth_policy.into(), + apps_needing_auth, + }) + } + + async fn remote_plugin_install_response( + &self, + remote_marketplace_name: String, + plugin_name: String, + ) -> Result { + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + if !config.features.enabled(Feature::Plugins) + || !config.features.enabled(Feature::RemotePlugin) + { + return Err(invalid_request(format!( + "remote plugin install is not enabled for marketplace {remote_marketplace_name}" + ))); + } + if plugin_name.is_empty() + || !plugin_name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '~') + { + return Err(invalid_request( + "invalid remote plugin id: only ASCII letters, digits, `_`, `-`, and `~` are allowed", + )); + } + + let auth = self.auth_manager.auth().await; + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail( + &remote_plugin_service_config, + auth.as_ref(), + &remote_marketplace_name, + &plugin_name, + ) + .await + .map_err(|err| { + remote_plugin_catalog_error_to_jsonrpc(err, "read remote plugin details before install") + })?; + if remote_detail.summary.install_policy == PluginInstallPolicy::NotAvailable { + return Err(invalid_request(format!( + "remote plugin {plugin_name} is not available for install" + ))); + } + + codex_core_plugins::remote::install_remote_plugin( + &remote_plugin_service_config, + auth.as_ref(), + &remote_marketplace_name, + &plugin_name, + ) + .await + .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "install remote plugin"))?; + + self.clear_plugin_related_caches(); + + let plugin_apps = remote_detail + .app_ids + .into_iter() + .map(codex_core::plugins::AppConnectorId) + .collect::>(); + let apps_needing_auth = self + .plugin_apps_needing_auth_for_install( + &config, + auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth), + &plugin_name, + &plugin_apps, + ) + .await; + + Ok(PluginInstallResponse { + auth_policy: remote_detail.summary.auth_policy, + apps_needing_auth, + }) + } + + async fn plugin_apps_needing_auth_for_install( + &self, + config: &Config, + is_chatgpt_auth: bool, + plugin_id: &str, + plugin_apps: &[codex_core::plugins::AppConnectorId], + ) -> Vec { + if plugin_apps.is_empty() || !config.features.apps_enabled_for_auth(is_chatgpt_auth) { + return Vec::new(); + } + + let environment_manager = self.thread_manager.environment_manager(); + let (all_connectors_result, accessible_connectors_result) = tokio::join!( + connectors::list_all_connectors_with_options(config, /*force_refetch*/ true), + connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( + config, + /*force_refetch*/ true, + &environment_manager + ), + ); + + let all_connectors = match all_connectors_result { + Ok(connectors) => connectors, + Err(err) => { + warn!( + plugin = plugin_id, + "failed to load app metadata after plugin install: {err:#}" + ); + connectors::list_cached_all_connectors(config) + .await + .unwrap_or_default() + } + }; + let all_connectors = connectors::connectors_for_plugin_apps(all_connectors, plugin_apps); + let (accessible_connectors, codex_apps_ready) = match accessible_connectors_result { + Ok(status) => (status.connectors, status.codex_apps_ready), + Err(err) => { + warn!( + plugin = plugin_id, + "failed to load accessible apps after plugin install: {err:#}" + ); + ( + connectors::list_cached_accessible_connectors_from_mcp_tools(config) + .await + .unwrap_or_default(), + false, + ) + } + }; + if !codex_apps_ready { + warn!( + plugin = plugin_id, + "codex_apps MCP not ready after plugin install; skipping appsNeedingAuth check" + ); + } + + plugin_app_helpers::plugin_apps_needing_auth( + &all_connectors, + &accessible_connectors, + plugin_apps, + codex_apps_ready, + ) + } + + pub(super) async fn plugin_uninstall( + &self, + request_id: ConnectionRequestId, + params: PluginUninstallParams, + ) { + let result = self.plugin_uninstall_response(params).await; + self.outgoing.send_result(request_id, result).await; + } + + async fn plugin_uninstall_response( + &self, + params: PluginUninstallParams, + ) -> Result { + let PluginUninstallParams { plugin_id } = params; + let plugins_manager = self.thread_manager.plugins_manager(); + + plugins_manager + .uninstall_plugin(plugin_id) + .await + .map_err(Self::plugin_uninstall_error)?; + self.clear_plugin_related_caches(); + Ok(PluginUninstallResponse {}) + } + + fn plugin_install_error(err: CorePluginInstallError) -> JSONRPCErrorError { + if err.is_invalid_request() { + return invalid_request(err.to_string()); + } + + match err { + CorePluginInstallError::Marketplace(err) => { + Self::marketplace_error(err, "install plugin") + } + CorePluginInstallError::Config(err) => { + internal_error(format!("failed to persist installed plugin config: {err}")) + } + CorePluginInstallError::Remote(err) => { + internal_error(format!("failed to enable remote plugin: {err}")) + } + CorePluginInstallError::Join(err) => { + internal_error(format!("failed to install plugin: {err}")) + } + CorePluginInstallError::Store(err) => { + internal_error(format!("failed to install plugin: {err}")) + } + } + } + + fn plugin_uninstall_error(err: CorePluginUninstallError) -> JSONRPCErrorError { + if err.is_invalid_request() { + return invalid_request(err.to_string()); + } + + match err { + CorePluginUninstallError::Config(err) => { + internal_error(format!("failed to clear plugin config: {err}")) + } + CorePluginUninstallError::Remote(err) => { + internal_error(format!("failed to uninstall remote plugin: {err}")) + } + CorePluginUninstallError::Join(err) => { + internal_error(format!("failed to uninstall plugin: {err}")) + } + CorePluginUninstallError::Store(err) => { + internal_error(format!("failed to uninstall plugin: {err}")) + } + CorePluginUninstallError::InvalidPluginId(_) => { + unreachable!("invalid plugin ids are handled above"); + } + } + } + + fn marketplace_error(err: MarketplaceError, action: &str) -> JSONRPCErrorError { + match err { + MarketplaceError::MarketplaceNotFound { .. } + | MarketplaceError::InvalidMarketplaceFile { .. } + | MarketplaceError::PluginNotFound { .. } + | MarketplaceError::PluginNotAvailable { .. } + | MarketplaceError::PluginsDisabled + | MarketplaceError::InvalidPlugin(_) => invalid_request(err.to_string()), + MarketplaceError::Io { .. } => internal_error(format!("failed to {action}: {err}")), + } + } +} + +fn remote_marketplace_to_info(marketplace: RemoteMarketplace) -> PluginMarketplaceEntry { + PluginMarketplaceEntry { + name: marketplace.name, + path: None, + interface: Some(MarketplaceInterface { + display_name: Some(marketplace.display_name), + }), + plugins: marketplace + .plugins + .into_iter() + .map(remote_plugin_summary_to_info) + .collect(), + } +} + +fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginSummary { + PluginSummary { + id: summary.id, + name: summary.name, + source: PluginSource::Remote, + installed: summary.installed, + enabled: summary.enabled, + install_policy: summary.install_policy, + auth_policy: summary.auth_policy, + interface: summary.interface, + } +} + +fn remote_plugin_detail_to_info( + detail: RemoteCatalogPluginDetail, + apps: Vec, +) -> PluginDetail { + PluginDetail { + marketplace_name: detail.marketplace_name, + marketplace_path: None, + summary: remote_plugin_summary_to_info(detail.summary), + description: detail.description, + skills: detail + .skills + .into_iter() + .map(|skill| SkillSummary { + name: skill.name, + description: skill.description, + short_description: skill.short_description, + interface: skill.interface, + path: None, + enabled: skill.enabled, + }) + .collect(), + apps, + mcp_servers: Vec::new(), + } +} + +fn remote_plugin_catalog_error_to_jsonrpc( + err: RemotePluginCatalogError, + context: &str, +) -> JSONRPCErrorError { + match err { + RemotePluginCatalogError::AuthRequired | RemotePluginCatalogError::UnsupportedAuthMode => { + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("{context}: {err}"), + data: None, + } + } + RemotePluginCatalogError::UnknownMarketplace { .. } + | RemotePluginCatalogError::MarketplaceMismatch { .. } => JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("{context}: {err}"), + data: None, + }, + RemotePluginCatalogError::UnexpectedStatus { status, .. } if status.as_u16() == 404 => { + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("{context}: {err}"), + data: None, + } + } + RemotePluginCatalogError::AuthToken(_) + | RemotePluginCatalogError::Request { .. } + | RemotePluginCatalogError::UnexpectedStatus { .. } + | RemotePluginCatalogError::Decode { .. } + | RemotePluginCatalogError::UnexpectedPluginId { .. } + | RemotePluginCatalogError::UnexpectedEnabledState { .. } => JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("{context}: {err}"), + data: None, + }, + } +} diff --git a/codex-rs/app-server/src/codex_message_processor/thread_goal_handlers.rs b/codex-rs/app-server/src/codex_message_processor/thread_goal_handlers.rs new file mode 100644 index 0000000000..049e0af21c --- /dev/null +++ b/codex-rs/app-server/src/codex_message_processor/thread_goal_handlers.rs @@ -0,0 +1,479 @@ +use super::*; +use codex_protocol::protocol::validate_thread_goal_objective; + +impl CodexMessageProcessor { + pub(super) async fn thread_goal_set( + &self, + request_id: ConnectionRequestId, + params: ThreadGoalSetParams, + ) { + if !self.config.features.enabled(Feature::Goals) { + self.send_invalid_request_error(request_id, "goals feature is disabled".to_string()) + .await; + return; + } + + let thread_id = match parse_thread_id_for_request(params.thread_id.as_str()) { + Ok(thread_id) => thread_id, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + let state_db = match self.state_db_for_materialized_thread(thread_id).await { + Ok(state_db) => state_db, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + let running_thread = self.thread_manager.get_thread(thread_id).await.ok(); + let rollout_path = match running_thread.as_ref() { + Some(thread) => match thread.rollout_path() { + Some(path) => path, + None => { + self.send_invalid_request_error( + request_id, + format!("ephemeral thread does not support goals: {thread_id}"), + ) + .await; + return; + } + }, + None => { + match find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string()) + .await + { + Ok(Some(path)) => path, + Ok(None) => { + self.send_invalid_request_error( + request_id, + format!("thread not found: {thread_id}"), + ) + .await; + return; + } + Err(err) => { + self.send_internal_error( + request_id, + format!("failed to locate thread id {thread_id}: {err}"), + ) + .await; + return; + } + } + } + }; + reconcile_rollout( + Some(&state_db), + rollout_path.as_path(), + self.config.model_provider_id.as_str(), + /*builder*/ None, + &[], + /*archived_only*/ None, + /*new_thread_memory_mode*/ None, + ) + .await; + + let listener_command_tx = { + let thread_state = self.thread_state_manager.thread_state(thread_id).await; + let thread_state = thread_state.lock().await; + thread_state.listener_command_tx() + }; + let status = params.status.map(thread_goal_status_to_state); + let objective = params.objective.as_deref().map(str::trim); + + if let Some(objective) = objective { + if let Err(message) = validate_thread_goal_objective(objective) { + self.send_invalid_request_error(request_id, message).await; + return; + } + if let Err(message) = validate_goal_budget(params.token_budget.flatten()) { + self.send_invalid_request_error(request_id, message).await; + return; + } + } else if let Some(token_budget) = params.token_budget + && let Err(message) = validate_goal_budget(token_budget) + { + self.send_invalid_request_error(request_id, message).await; + return; + } + + if let Some(thread) = running_thread.as_ref() { + thread.prepare_external_goal_mutation().await; + } + + let goal = if let Some(objective) = objective { + match state_db.get_thread_goal(thread_id).await { + Ok(goal) => { + if let Some(goal) = goal.as_ref().filter(|goal| { + goal.objective == objective + && goal.status != codex_state::ThreadGoalStatus::Complete + }) { + state_db + .update_thread_goal( + thread_id, + codex_state::ThreadGoalUpdate { + status, + token_budget: params.token_budget, + expected_goal_id: Some(goal.goal_id.clone()), + }, + ) + .await + .and_then(|goal| { + goal.ok_or_else(|| { + anyhow::anyhow!( + "cannot update goal for thread {thread_id}: no goal exists" + ) + }) + }) + } else { + state_db + .replace_thread_goal( + thread_id, + objective, + status.unwrap_or(codex_state::ThreadGoalStatus::Active), + params.token_budget.flatten(), + ) + .await + } + } + Err(err) => Err(err), + } + } else { + state_db + .update_thread_goal( + thread_id, + codex_state::ThreadGoalUpdate { + status, + token_budget: params.token_budget, + expected_goal_id: None, + }, + ) + .await + .and_then(|goal| { + goal.ok_or_else(|| { + anyhow::anyhow!("cannot update goal for thread {thread_id}: no goal exists") + }) + }) + }; + + let goal = match goal { + Ok(goal) => goal, + Err(err) => { + self.send_invalid_request_error(request_id, err.to_string()) + .await; + return; + } + }; + let goal_status = goal.status; + let goal = api_thread_goal_from_state(goal); + self.outgoing + .send_response( + request_id.clone(), + ThreadGoalSetResponse { goal: goal.clone() }, + ) + .await; + self.emit_thread_goal_updated_ordered(thread_id, goal, listener_command_tx) + .await; + if let Some(thread) = running_thread.as_ref() { + thread.apply_external_goal_set(goal_status).await; + } + } + + pub(super) async fn thread_goal_get( + &self, + request_id: ConnectionRequestId, + params: ThreadGoalGetParams, + ) { + if !self.config.features.enabled(Feature::Goals) { + self.send_invalid_request_error(request_id, "goals feature is disabled".to_string()) + .await; + return; + } + + let thread_id = match parse_thread_id_for_request(params.thread_id.as_str()) { + Ok(thread_id) => thread_id, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + let state_db = match self.state_db_for_materialized_thread(thread_id).await { + Ok(state_db) => state_db, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + let goal = match state_db.get_thread_goal(thread_id).await { + Ok(goal) => goal.map(api_thread_goal_from_state), + Err(err) => { + self.send_internal_error(request_id, format!("failed to read thread goal: {err}")) + .await; + return; + } + }; + self.outgoing + .send_response(request_id, ThreadGoalGetResponse { goal }) + .await; + } + + pub(super) async fn thread_goal_clear( + &self, + request_id: ConnectionRequestId, + params: ThreadGoalClearParams, + ) { + if !self.config.features.enabled(Feature::Goals) { + self.send_invalid_request_error(request_id, "goals feature is disabled".to_string()) + .await; + return; + } + + let thread_id = match parse_thread_id_for_request(params.thread_id.as_str()) { + Ok(thread_id) => thread_id, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + let state_db = match self.state_db_for_materialized_thread(thread_id).await { + Ok(state_db) => state_db, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + let running_thread = self.thread_manager.get_thread(thread_id).await.ok(); + let rollout_path = match running_thread.as_ref() { + Some(thread) => match thread.rollout_path() { + Some(path) => path, + None => { + self.send_invalid_request_error( + request_id, + format!("ephemeral thread does not support goals: {thread_id}"), + ) + .await; + return; + } + }, + None => { + match find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string()) + .await + { + Ok(Some(path)) => path, + Ok(None) => { + self.send_invalid_request_error( + request_id, + format!("thread not found: {thread_id}"), + ) + .await; + return; + } + Err(err) => { + self.send_internal_error( + request_id, + format!("failed to locate thread id {thread_id}: {err}"), + ) + .await; + return; + } + } + } + }; + reconcile_rollout( + Some(&state_db), + rollout_path.as_path(), + self.config.model_provider_id.as_str(), + /*builder*/ None, + &[], + /*archived_only*/ None, + /*new_thread_memory_mode*/ None, + ) + .await; + + if let Some(thread) = running_thread.as_ref() { + thread.prepare_external_goal_mutation().await; + } + + let listener_command_tx = { + let thread_state = self.thread_state_manager.thread_state(thread_id).await; + let thread_state = thread_state.lock().await; + thread_state.listener_command_tx() + }; + let cleared = match state_db.delete_thread_goal(thread_id).await { + Ok(cleared) => cleared, + Err(err) => { + self.send_internal_error(request_id, format!("failed to clear thread goal: {err}")) + .await; + return; + } + }; + + if cleared && let Some(thread) = running_thread.as_ref() { + thread.apply_external_goal_clear().await; + } + + self.outgoing + .send_response(request_id, ThreadGoalClearResponse { cleared }) + .await; + if cleared { + self.emit_thread_goal_cleared_ordered(thread_id, listener_command_tx) + .await; + } + } + + async fn state_db_for_materialized_thread( + &self, + thread_id: ThreadId, + ) -> Result { + if let Ok(thread) = self.thread_manager.get_thread(thread_id).await { + if thread.rollout_path().is_none() { + return Err(invalid_request(format!( + "ephemeral thread does not support goals: {thread_id}" + ))); + } + if let Some(state_db) = thread.state_db() { + return Ok(state_db); + } + } else { + match find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string()).await + { + Ok(Some(_)) => {} + Ok(None) => { + return Err(invalid_request(format!("thread not found: {thread_id}"))); + } + Err(err) => { + return Err(internal_error(format!( + "failed to locate thread id {thread_id}: {err}" + ))); + } + } + } + + open_state_db_for_direct_thread_lookup(&self.config) + .await + .ok_or_else(|| internal_error("sqlite state db unavailable for thread goals")) + } + + pub(super) async fn emit_thread_goal_snapshot(&self, thread_id: ThreadId) { + let state_db = match self.state_db_for_materialized_thread(thread_id).await { + Ok(state_db) => state_db, + Err(err) => { + warn!( + "failed to open state db before emitting thread goal resume snapshot for {thread_id}: {}", + err.message + ); + return; + } + }; + let listener_command_tx = { + let thread_state = self.thread_state_manager.thread_state(thread_id).await; + let thread_state = thread_state.lock().await; + thread_state.listener_command_tx() + }; + if let Some(listener_command_tx) = listener_command_tx { + let command = crate::thread_state::ThreadListenerCommand::EmitThreadGoalSnapshot { + state_db: state_db.clone(), + }; + if listener_command_tx.send(command).is_ok() { + return; + } + warn!( + "failed to enqueue thread goal snapshot for {thread_id}: listener command channel is closed" + ); + } + send_thread_goal_snapshot_notification(&self.outgoing, thread_id, &state_db).await; + } + + async fn emit_thread_goal_updated_ordered( + &self, + thread_id: ThreadId, + goal: ThreadGoal, + listener_command_tx: Option>, + ) { + if let Some(listener_command_tx) = listener_command_tx { + let command = crate::thread_state::ThreadListenerCommand::EmitThreadGoalUpdated { + goal: goal.clone(), + }; + if listener_command_tx.send(command).is_ok() { + return; + } + warn!( + "failed to enqueue thread goal update for {thread_id}: listener command channel is closed" + ); + } + self.outgoing + .send_server_notification(ServerNotification::ThreadGoalUpdated( + ThreadGoalUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: None, + goal, + }, + )) + .await; + } + + async fn emit_thread_goal_cleared_ordered( + &self, + thread_id: ThreadId, + listener_command_tx: Option>, + ) { + if let Some(listener_command_tx) = listener_command_tx { + let command = crate::thread_state::ThreadListenerCommand::EmitThreadGoalCleared; + if listener_command_tx.send(command).is_ok() { + return; + } + warn!( + "failed to enqueue thread goal clear for {thread_id}: listener command channel is closed" + ); + } + self.outgoing + .send_server_notification(ServerNotification::ThreadGoalCleared( + ThreadGoalClearedNotification { + thread_id: thread_id.to_string(), + }, + )) + .await; + } +} + +fn validate_goal_budget(value: Option) -> Result<(), String> { + if let Some(value) = value + && value <= 0 + { + return Err("goal budgets must be positive when provided".to_string()); + } + Ok(()) +} + +fn thread_goal_status_to_state(status: ThreadGoalStatus) -> codex_state::ThreadGoalStatus { + match status { + ThreadGoalStatus::Active => codex_state::ThreadGoalStatus::Active, + ThreadGoalStatus::Paused => codex_state::ThreadGoalStatus::Paused, + ThreadGoalStatus::BudgetLimited => codex_state::ThreadGoalStatus::BudgetLimited, + ThreadGoalStatus::Complete => codex_state::ThreadGoalStatus::Complete, + } +} + +fn thread_goal_status_from_state(status: codex_state::ThreadGoalStatus) -> ThreadGoalStatus { + match status { + codex_state::ThreadGoalStatus::Active => ThreadGoalStatus::Active, + codex_state::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused, + codex_state::ThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited, + codex_state::ThreadGoalStatus::Complete => ThreadGoalStatus::Complete, + } +} + +pub(super) fn api_thread_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal { + ThreadGoal { + thread_id: goal.thread_id.to_string(), + objective: goal.objective, + status: thread_goal_status_from_state(goal.status), + token_budget: goal.token_budget, + tokens_used: goal.tokens_used, + time_used_seconds: goal.time_used_seconds, + created_at: goal.created_at.timestamp(), + updated_at: goal.updated_at.timestamp(), + } +} diff --git a/codex-rs/app-server/src/codex_message_processor/token_usage_replay.rs b/codex-rs/app-server/src/codex_message_processor/token_usage_replay.rs index d3c06f5e7c..bcd972a470 100644 --- a/codex-rs/app-server/src/codex_message_processor/token_usage_replay.rs +++ b/codex-rs/app-server/src/codex_message_processor/token_usage_replay.rs @@ -17,6 +17,7 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; +use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStatus; use codex_core::CodexThread; use codex_protocol::ThreadId; @@ -60,15 +61,15 @@ pub(super) async fn send_thread_token_usage_update_to_connection( pub(super) async fn latest_token_usage_turn_id_for_thread_path(thread: &Thread) -> Option { let rollout_path = thread.path.as_deref()?; - latest_token_usage_turn_id_from_rollout_path(rollout_path, thread).await + latest_token_usage_turn_id_from_rollout_path(rollout_path, thread.turns.as_slice()).await } pub(super) async fn latest_token_usage_turn_id_from_rollout_path( rollout_path: &Path, - thread: &Thread, + turns: &[Turn], ) -> Option { let rollout_items = read_rollout_items_from_rollout(rollout_path).await.ok()?; - latest_token_usage_turn_id_from_rollout_items(&rollout_items, thread) + latest_token_usage_turn_id_from_rollout_items(&rollout_items, turns) } /// Identifies the turn that was active when a `TokenCount` record appeared. @@ -82,21 +83,8 @@ struct TokenUsageTurnOwner { pub(super) fn latest_token_usage_turn_id_from_rollout_items( rollout_items: &[RolloutItem], - thread: &Thread, + turns: &[Turn], ) -> Option { - let owner = latest_token_usage_turn_owner_from_rollout_items(rollout_items)?; - if thread.turns.iter().any(|turn| turn.id == owner.id) { - return Some(owner.id); - } - owner - .position - .and_then(|position| thread.turns.get(position)) - .map(|turn| turn.id.clone()) -} - -fn latest_token_usage_turn_owner_from_rollout_items( - rollout_items: &[RolloutItem], -) -> Option { let mut builder = ThreadHistoryBuilder::new(); let mut token_usage_turn_owner = None; @@ -113,7 +101,15 @@ fn latest_token_usage_turn_owner_from_rollout_items( builder.handle_rollout_item(item); } - token_usage_turn_owner + let owner = token_usage_turn_owner?; + if turns.iter().any(|turn| turn.id == owner.id) { + Some(owner.id) + } else { + owner + .position + .and_then(|position| turns.get(position)) + .map(|turn| turn.id.clone()) + } } /// Chooses a fallback turn id that should own a replayed token usage update. diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index b72c84e906..18077a9942 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -34,9 +34,9 @@ use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::sync::watch; -use crate::error_code::INTERNAL_ERROR_CODE; -use crate::error_code::INVALID_PARAMS_ERROR_CODE; -use crate::error_code::INVALID_REQUEST_ERROR_CODE; +use crate::error_code::internal_error; +use crate::error_code::invalid_params; +use crate::error_code::invalid_request; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::ConnectionRequestId; use crate::outgoing_message::OutgoingMessageSender; @@ -158,7 +158,7 @@ impl CommandExecManager { } = params; if process_id.is_none() && (tty || stream_stdin || stream_stdout_stderr) { return Err(invalid_request( - "command/exec tty or streaming requires a client-supplied processId".to_string(), + "command/exec tty or streaming requires a client-supplied processId", )); } let process_id = process_id.map_or_else( @@ -178,12 +178,12 @@ impl CommandExecManager { if matches!(exec_request.sandbox, SandboxType::WindowsRestrictedToken) { if tty || stream_stdin || stream_stdout_stderr { return Err(invalid_request( - "streaming command/exec is not supported with windows sandbox".to_string(), + "streaming command/exec is not supported with windows sandbox", )); } if output_bytes_cap != Some(DEFAULT_OUTPUT_BYTES_CAP) { return Err(invalid_request( - "custom outputBytesCap is not supported with windows sandbox".to_string(), + "custom outputBytesCap is not supported with windows sandbox", )); } if let InternalProcessId::Client(_) = &process_id { @@ -249,7 +249,7 @@ impl CommandExecManager { let sessions = Arc::clone(&self.sessions); let (program, args) = command .split_first() - .ok_or_else(|| invalid_request("command must not be empty".to_string()))?; + .ok_or_else(|| invalid_request("command must not be empty"))?; { let mut sessions = self.sessions.lock().await; if sessions.contains_key(&process_key) { @@ -312,7 +312,7 @@ impl CommandExecManager { ) -> Result { if params.delta_base64.is_none() && !params.close_stdin { return Err(invalid_params( - "command/exec/write requires deltaBase64 or closeStdin".to_string(), + "command/exec/write requires deltaBase64 or closeStdin", )); } @@ -421,7 +421,7 @@ impl CommandExecManager { }; let CommandExecSession::Active { control_tx } = session else { return Err(invalid_request( - "command/exec/write, command/exec/terminate, and command/exec/resize are not supported for windows sandbox processes".to_string(), + "command/exec/write, command/exec/terminate, and command/exec/resize are not supported for windows sandbox processes", )); }; let (response_tx, response_rx) = oneshot::channel(); @@ -635,7 +635,7 @@ async fn handle_process_write( ) -> Result<(), JSONRPCErrorError> { if !stream_stdin { return Err(invalid_request( - "stdin streaming is not enabled for this command/exec".to_string(), + "stdin streaming is not enabled for this command/exec", )); } if !delta.is_empty() { @@ -643,7 +643,7 @@ async fn handle_process_write( .writer_sender() .send(delta) .await - .map_err(|_| invalid_request("stdin is already closed".to_string()))?; + .map_err(|_| invalid_request("stdin is already closed"))?; } if close_stdin { session.close_stdin(); @@ -665,7 +665,7 @@ pub(crate) fn terminal_size_from_protocol( ) -> Result { if size.rows == 0 || size.cols == 0 { return Err(invalid_params( - "command/exec size rows and cols must be greater than 0".to_string(), + "command/exec size rows and cols must be greater than 0", )); } Ok(TerminalSize { @@ -681,39 +681,13 @@ fn command_no_longer_running_error(process_id: &InternalProcessId) -> JSONRPCErr )) } -fn invalid_request(message: String) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message, - data: None, - } -} - -fn invalid_params(message: String) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INVALID_PARAMS_ERROR_CODE, - message, - data: None, - } -} - -fn internal_error(message: String) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message, - data: None, - } -} - #[cfg(test)] mod tests { use std::collections::HashMap; + use crate::error_code::INVALID_REQUEST_ERROR_CODE; use codex_protocol::config_types::WindowsSandboxLevel; - use codex_protocol::permissions::FileSystemSandboxPolicy; - use codex_protocol::permissions::NetworkSandboxPolicy; - use codex_protocol::protocol::ReadOnlyAccess; - use codex_protocol::protocol::SandboxPolicy; + use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; #[cfg(not(target_os = "windows"))] @@ -730,13 +704,10 @@ mod tests { use crate::outgoing_message::OutgoingMessage; fn windows_sandbox_exec_request() -> ExecRequest { - let sandbox_policy = SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, - network_access: false, - }; + let cwd = AbsolutePathBuf::current_dir().expect("current dir"); ExecRequest::new( vec!["cmd".to_string()], - AbsolutePathBuf::current_dir().expect("current dir"), + cwd, HashMap::new(), /*network*/ None, ExecExpiration::DefaultTimeout, @@ -744,9 +715,7 @@ mod tests { SandboxType::WindowsRestrictedToken, WindowsSandboxLevel::Disabled, /*windows_sandbox_private_desktop*/ false, - sandbox_policy.clone(), - FileSystemSandboxPolicy::from(&sandbox_policy), - NetworkSandboxPolicy::from(&sandbox_policy), + PermissionProfile::read_only(), /*arg0*/ None, ) } @@ -836,10 +805,7 @@ mod tests { connection_id: ConnectionId(8), request_id: codex_app_server_protocol::RequestId::Integer(100), }; - let sandbox_policy = SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, - network_access: false, - }; + let cwd = AbsolutePathBuf::current_dir().expect("current dir"); manager .start(StartCommandExecParams { @@ -848,7 +814,7 @@ mod tests { process_id: Some("proc-100".to_string()), exec_request: ExecRequest::new( vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()], - AbsolutePathBuf::current_dir().expect("current dir"), + cwd.clone(), HashMap::new(), /*network*/ None, ExecExpiration::Cancellation(CancellationToken::new()), @@ -856,9 +822,7 @@ mod tests { SandboxType::None, WindowsSandboxLevel::Disabled, /*windows_sandbox_private_desktop*/ false, - sandbox_policy.clone(), - FileSystemSandboxPolicy::from(&sandbox_policy), - NetworkSandboxPolicy::from(&sandbox_policy), + PermissionProfile::read_only(), /*arg0*/ None, ), started_network_proxy: None, diff --git a/codex-rs/app-server/src/config/external_agent_config.rs b/codex-rs/app-server/src/config/external_agent_config.rs index 1e57cd4a4d..9ecf06bcf0 100644 --- a/codex-rs/app-server/src/config/external_agent_config.rs +++ b/codex-rs/app-server/src/config/external_agent_config.rs @@ -1,14 +1,14 @@ use codex_config::types::PluginConfig; use codex_core::config::Config; use codex_core::config::ConfigBuilder; -use codex_core::plugins::MarketplaceAddRequest; use codex_core::plugins::PluginId; use codex_core::plugins::PluginInstallRequest; use codex_core::plugins::PluginsManager; -use codex_core::plugins::add_marketplace; -use codex_core::plugins::is_local_marketplace_source; use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy; use codex_core_plugins::marketplace::find_marketplace_manifest_path; +use codex_core_plugins::marketplace_add::MarketplaceAddRequest; +use codex_core_plugins::marketplace_add::add_marketplace; +use codex_core_plugins::marketplace_add::is_local_marketplace_source; use codex_protocol::protocol::Product; use serde_json::Value as JsonValue; use std::collections::BTreeMap; diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 5fac8d8386..e8bb82777c 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -1,7 +1,8 @@ use crate::config_manager::ConfigManager; use crate::config_manager_service::ConfigManagerError; -use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; +use crate::error_code::internal_error; +use crate::error_code::invalid_request; use async_trait::async_trait; use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ConfigBatchWriteParams; @@ -12,18 +13,25 @@ use codex_app_server_protocol::ConfigRequirementsReadResponse; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWriteErrorCode; use codex_app_server_protocol::ConfigWriteResponse; +use codex_app_server_protocol::ConfiguredHookHandler; +use codex_app_server_protocol::ConfiguredHookMatcherGroup; use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse; use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::ManagedHooksRequirements; use codex_app_server_protocol::NetworkDomainPermission; use codex_app_server_protocol::NetworkRequirements; use codex_app_server_protocol::NetworkUnixSocketPermission; use codex_app_server_protocol::SandboxMode; +use codex_config::ConfigRequirementsToml; +use codex_config::HookEventsToml; +use codex_config::HookHandlerConfig as CoreHookHandlerConfig; +use codex_config::ManagedHooksRequirementsToml; +use codex_config::MatcherGroup as CoreMatcherGroup; +use codex_config::ResidencyRequirement as CoreResidencyRequirement; +use codex_config::SandboxModeRequirement as CoreSandboxModeRequirement; use codex_core::ThreadManager; use codex_core::config::Config; -use codex_core::config_loader::ConfigRequirementsToml; -use codex_core::config_loader::ResidencyRequirement as CoreResidencyRequirement; -use codex_core::config_loader::SandboxModeRequirement as CoreSandboxModeRequirement; use codex_core::plugins::PluginId; use codex_core_plugins::loader::installed_plugin_telemetry_metadata; use codex_core_plugins::toggles::collect_plugin_enabled_candidates; @@ -38,6 +46,7 @@ use tracing::warn; const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ "apps", + "memories", "plugins", "tool_search", "tool_suggest", @@ -91,10 +100,10 @@ impl ConfigApi { self.config_manager .load_latest_config(fallback_cwd) .await - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to resolve feature override precedence: {err}"), - data: None, + .map_err(|err| { + internal_error(format!( + "failed to resolve feature override precedence: {err}" + )) }) } @@ -189,14 +198,10 @@ impl ConfigApi { continue; } - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!( - "unsupported feature enablement `{key}`: currently supported features are {}", - SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ") - ), - data: None, - }); + return Err(invalid_request(format!( + "unsupported feature enablement `{key}`: currently supported features are {}", + SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ") + ))); } let message = if let Some(feature) = feature_for_key(key) { @@ -207,11 +212,7 @@ impl ConfigApi { } else { format!("invalid feature enablement `{key}`") }; - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message, - data: None, - }); + return Err(invalid_request(message)); } if enablement.is_empty() { @@ -224,11 +225,7 @@ impl ConfigApi { .iter() .map(|(name, enabled)| (name.clone(), *enabled)), ) - .map_err(|_| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: "failed to update feature enablement".to_string(), - data: None, - })?; + .map_err(|_| internal_error("failed to update feature enablement"))?; self.load_latest_config(/*fallback_cwd*/ None).await?; self.user_config_reloader.reload_user_config().await; @@ -289,6 +286,7 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR feature_requirements: requirements .feature_requirements .map(|requirements| requirements.entries), + hooks: requirements.hooks.map(map_hooks_requirements_to_api), enforce_residency: requirements .enforce_residency .map(map_residency_requirement_to_api), @@ -296,6 +294,71 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR } } +fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> ManagedHooksRequirements { + let ManagedHooksRequirementsToml { + managed_dir, + windows_managed_dir, + hooks, + } = hooks; + let HookEventsToml { + pre_tool_use, + permission_request, + post_tool_use, + session_start, + user_prompt_submit, + stop, + } = hooks; + + ManagedHooksRequirements { + managed_dir, + windows_managed_dir, + pre_tool_use: map_hook_matcher_groups_to_api(pre_tool_use), + permission_request: map_hook_matcher_groups_to_api(permission_request), + post_tool_use: map_hook_matcher_groups_to_api(post_tool_use), + session_start: map_hook_matcher_groups_to_api(session_start), + user_prompt_submit: map_hook_matcher_groups_to_api(user_prompt_submit), + stop: map_hook_matcher_groups_to_api(stop), + } +} + +fn map_hook_matcher_groups_to_api( + groups: Vec, +) -> Vec { + groups + .into_iter() + .map(map_hook_matcher_group_to_api) + .collect() +} + +fn map_hook_matcher_group_to_api(group: CoreMatcherGroup) -> ConfiguredHookMatcherGroup { + ConfiguredHookMatcherGroup { + matcher: group.matcher, + hooks: group + .hooks + .into_iter() + .map(map_hook_handler_to_api) + .collect(), + } +} + +fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHandler { + match handler { + CoreHookHandlerConfig::Command { + command, + timeout_sec, + r#async, + status_message, + } => ConfiguredHookHandler::Command { + command, + timeout_sec, + r#async, + status_message, + }, + CoreHookHandlerConfig::Prompt {} => ConfiguredHookHandler::Prompt {}, + CoreHookHandlerConfig::Agent {} => ConfiguredHookHandler::Agent {}, + } +} + fn map_sandbox_mode_requirement_to_api(mode: CoreSandboxModeRequirement) -> Option { match mode { CoreSandboxModeRequirement::ReadOnly => Some(SandboxMode::ReadOnly), @@ -314,20 +377,20 @@ fn map_residency_requirement_to_api( } fn map_network_requirements_to_api( - network: codex_core::config_loader::NetworkRequirementsToml, + network: codex_config::NetworkRequirementsToml, ) -> NetworkRequirements { let allowed_domains = network .domains .as_ref() - .and_then(codex_core::config_loader::NetworkDomainPermissionsToml::allowed_domains); + .and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains); let denied_domains = network .domains .as_ref() - .and_then(codex_core::config_loader::NetworkDomainPermissionsToml::denied_domains); + .and_then(codex_config::NetworkDomainPermissionsToml::denied_domains); let allow_unix_sockets = network .unix_sockets .as_ref() - .map(codex_core::config_loader::NetworkUnixSocketPermissionsToml::allow_unix_sockets) + .map(codex_config::NetworkUnixSocketPermissionsToml::allow_unix_sockets) .filter(|entries| !entries.is_empty()); NetworkRequirements { @@ -364,28 +427,20 @@ fn map_network_requirements_to_api( } fn map_network_domain_permission_to_api( - permission: codex_core::config_loader::NetworkDomainPermissionToml, + permission: codex_config::NetworkDomainPermissionToml, ) -> NetworkDomainPermission { match permission { - codex_core::config_loader::NetworkDomainPermissionToml::Allow => { - NetworkDomainPermission::Allow - } - codex_core::config_loader::NetworkDomainPermissionToml::Deny => { - NetworkDomainPermission::Deny - } + codex_config::NetworkDomainPermissionToml::Allow => NetworkDomainPermission::Allow, + codex_config::NetworkDomainPermissionToml::Deny => NetworkDomainPermission::Deny, } } fn map_network_unix_socket_permission_to_api( - permission: codex_core::config_loader::NetworkUnixSocketPermissionToml, + permission: codex_config::NetworkUnixSocketPermissionToml, ) -> NetworkUnixSocketPermission { match permission { - codex_core::config_loader::NetworkUnixSocketPermissionToml::Allow => { - NetworkUnixSocketPermission::Allow - } - codex_core::config_loader::NetworkUnixSocketPermissionToml::None => { - NetworkUnixSocketPermission::None - } + codex_config::NetworkUnixSocketPermissionToml::Allow => NetworkUnixSocketPermission::Allow, + codex_config::NetworkUnixSocketPermissionToml::None => NetworkUnixSocketPermission::None, } } @@ -394,11 +449,7 @@ fn map_error(err: ConfigManagerError) -> JSONRPCErrorError { return config_write_error(code, err.to_string()); } - JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: err.to_string(), - data: None, - } + internal_error(err.to_string()) } fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> JSONRPCErrorError { @@ -417,13 +468,13 @@ mod tests { use crate::config_manager::apply_runtime_feature_enablement; use codex_analytics::AnalyticsEventsClient; use codex_arg0::Arg0DispatchPaths; - use codex_core::config_loader::CloudRequirementsLoader; - use codex_core::config_loader::LoaderOverrides; - use codex_core::config_loader::NetworkDomainPermissionToml as CoreNetworkDomainPermissionToml; - use codex_core::config_loader::NetworkDomainPermissionsToml as CoreNetworkDomainPermissionsToml; - use codex_core::config_loader::NetworkRequirementsToml as CoreNetworkRequirementsToml; - use codex_core::config_loader::NetworkUnixSocketPermissionToml as CoreNetworkUnixSocketPermissionToml; - use codex_core::config_loader::NetworkUnixSocketPermissionsToml as CoreNetworkUnixSocketPermissionsToml; + use codex_config::CloudRequirementsLoader; + use codex_config::LoaderOverrides; + use codex_config::NetworkDomainPermissionToml as CoreNetworkDomainPermissionToml; + use codex_config::NetworkDomainPermissionsToml as CoreNetworkDomainPermissionsToml; + use codex_config::NetworkRequirementsToml as CoreNetworkRequirementsToml; + use codex_config::NetworkUnixSocketPermissionToml as CoreNetworkUnixSocketPermissionToml; + use codex_config::NetworkUnixSocketPermissionsToml as CoreNetworkUnixSocketPermissionsToml; use codex_features::Feature; use codex_login::AuthManager; use codex_login::CodexAuth; @@ -458,23 +509,37 @@ mod tests { ]), allowed_approvals_reviewers: Some(vec![ CoreApprovalsReviewer::User, - CoreApprovalsReviewer::GuardianSubagent, + CoreApprovalsReviewer::AutoReview, ]), allowed_sandbox_modes: Some(vec![ CoreSandboxModeRequirement::ReadOnly, CoreSandboxModeRequirement::ExternalSandbox, ]), remote_sandbox_config: None, - allowed_web_search_modes: Some(vec![ - codex_core::config_loader::WebSearchModeRequirement::Cached, - ]), + allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]), guardian_policy_config: None, - feature_requirements: Some(codex_core::config_loader::FeatureRequirementsToml { + feature_requirements: Some(codex_config::FeatureRequirementsToml { entries: std::collections::BTreeMap::from([ ("apps".to_string(), false), ("personality".to_string(), true), ]), }), + hooks: Some(ManagedHooksRequirementsToml { + managed_dir: Some(PathBuf::from("/enterprise/hooks")), + windows_managed_dir: Some(PathBuf::from(r"C:\enterprise\hooks")), + hooks: HookEventsToml { + pre_tool_use: vec![CoreMatcherGroup { + matcher: Some("^Bash$".to_string()), + hooks: vec![CoreHookHandlerConfig::Command { + command: "python3 /enterprise/hooks/pre.py".to_string(), + timeout_sec: Some(10), + r#async: false, + status_message: Some("checking".to_string()), + }], + }], + ..Default::default() + }, + }), mcp_servers: None, apps: None, rules: None, @@ -523,7 +588,7 @@ mod tests { mapped.allowed_approvals_reviewers, Some(vec![ codex_app_server_protocol::ApprovalsReviewer::User, - codex_app_server_protocol::ApprovalsReviewer::GuardianSubagent, + codex_app_server_protocol::ApprovalsReviewer::AutoReview, ]) ); assert_eq!( @@ -541,6 +606,27 @@ mod tests { ("personality".to_string(), true), ])), ); + assert_eq!( + mapped.hooks, + Some(ManagedHooksRequirements { + managed_dir: Some(PathBuf::from("/enterprise/hooks")), + windows_managed_dir: Some(PathBuf::from(r"C:\enterprise\hooks")), + pre_tool_use: vec![ConfiguredHookMatcherGroup { + matcher: Some("^Bash$".to_string()), + hooks: vec![ConfiguredHookHandler::Command { + command: "python3 /enterprise/hooks/pre.py".to_string(), + timeout_sec: Some(10), + r#async: false, + status_message: Some("checking".to_string()), + }], + }], + permission_request: Vec::new(), + post_tool_use: Vec::new(), + session_start: Vec::new(), + user_prompt_submit: Vec::new(), + stop: Vec::new(), + }), + ); assert_eq!( mapped.enforce_residency, Some(codex_app_server_protocol::ResidencyRequirement::Us), @@ -581,6 +667,7 @@ mod tests { allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -640,6 +727,7 @@ mod tests { allowed_web_search_modes: Some(Vec::new()), guardian_policy_config: None, feature_requirements: None, + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -696,11 +784,9 @@ mod tests { )]) .cloud_requirements(CloudRequirementsLoader::new(async { Ok(Some(ConfigRequirementsToml { - feature_requirements: Some( - codex_core::config_loader::FeatureRequirementsToml { - entries: BTreeMap::from([("apps".to_string(), false)]), - }, - ), + feature_requirements: Some(codex_config::FeatureRequirementsToml { + entries: BTreeMap::from([("apps".to_string(), false)]), + }), ..Default::default() })) })) diff --git a/codex-rs/app-server/src/config_manager.rs b/codex-rs/app-server/src/config_manager.rs index 69b819c152..ba11205b7a 100644 --- a/codex-rs/app-server/src/config_manager.rs +++ b/codex-rs/app-server/src/config_manager.rs @@ -1,12 +1,12 @@ use codex_arg0::Arg0DispatchPaths; use codex_cloud_requirements::cloud_requirements_loader; +use codex_config::CloudRequirementsLoader; +use codex_config::ConfigLayerStack; +use codex_config::LoaderOverrides; use codex_config::ThreadConfigLoader; +use codex_config::loader::load_config_layers_state; use codex_core::config::Config; use codex_core::config::ConfigOverrides; -use codex_core::config_loader::CloudRequirementsLoader; -use codex_core::config_loader::ConfigLayerStack; -use codex_core::config_loader::LoaderOverrides; -use codex_core::config_loader::load_config_layers_state; use codex_exec_server::LOCAL_FS; use codex_features::feature_for_key; use codex_login::AuthManager; @@ -32,8 +32,7 @@ pub(crate) struct ConfigManager { loader_overrides: LoaderOverrides, cloud_requirements: Arc>, arg0_paths: Arg0DispatchPaths, - thread_config_loader: Arc, - host_name: Option, + thread_config_loader: Arc>>, } impl ConfigManager { @@ -44,27 +43,6 @@ impl ConfigManager { cloud_requirements: CloudRequirementsLoader, arg0_paths: Arg0DispatchPaths, thread_config_loader: Arc, - ) -> Self { - Self::new_with_host_name( - codex_home, - cli_overrides, - loader_overrides, - cloud_requirements, - arg0_paths, - thread_config_loader, - codex_config::host_name(), - ) - } - - #[allow(clippy::too_many_arguments)] - fn new_with_host_name( - codex_home: PathBuf, - cli_overrides: Vec<(String, TomlValue)>, - loader_overrides: LoaderOverrides, - cloud_requirements: CloudRequirementsLoader, - arg0_paths: Arg0DispatchPaths, - thread_config_loader: Arc, - host_name: Option, ) -> Self { Self { codex_home, @@ -73,8 +51,7 @@ impl ConfigManager { loader_overrides, cloud_requirements: Arc::new(RwLock::new(cloud_requirements)), arg0_paths, - thread_config_loader, - host_name, + thread_config_loader: Arc::new(RwLock::new(thread_config_loader)), } } @@ -120,6 +97,24 @@ impl ConfigManager { } } + pub(crate) fn replace_thread_config_loader( + &self, + thread_config_loader: Arc, + ) { + if let Ok(mut guard) = self.thread_config_loader.write() { + *guard = thread_config_loader; + } else { + warn!("failed to update thread config loader"); + } + } + + fn current_thread_config_loader(&self) -> Arc { + self.thread_config_loader + .read() + .map(|guard| Arc::clone(&*guard)) + .unwrap_or_else(|_| Arc::new(codex_config::NoopThreadConfigLoader)) + } + pub(crate) async fn sync_default_client_residency_requirement(&self) { match self.load_latest_config(/*fallback_cwd*/ None).await { Ok(config) => { @@ -210,8 +205,7 @@ impl ConfigManager { .harness_overrides(typesafe_overrides) .fallback_cwd(fallback_cwd) .cloud_requirements(self.current_cloud_requirements()) - .thread_config_loader(Arc::clone(&self.thread_config_loader)) - .host_name(self.host_name.clone()) + .thread_config_loader(self.current_thread_config_loader()) .build() .await?; self.apply_runtime_feature_enablement(&mut config); @@ -230,6 +224,7 @@ impl ConfigManager { &self, cwd: Option, ) -> std::io::Result { + let thread_config_loader = self.current_thread_config_loader(); load_config_layers_state( LOCAL_FS.as_ref(), &self.codex_home, @@ -237,8 +232,7 @@ impl ConfigManager { &self.current_cli_overrides(), self.loader_overrides.clone(), self.current_cloud_requirements(), - self.thread_config_loader.as_ref(), - self.host_name.as_deref(), + thread_config_loader.as_ref(), ) .await } @@ -266,16 +260,14 @@ impl ConfigManager { cli_overrides: Vec<(String, TomlValue)>, loader_overrides: LoaderOverrides, cloud_requirements: CloudRequirementsLoader, - host_name: Option, ) -> Self { - Self::new_with_host_name( + Self::new( codex_home, cli_overrides, loader_overrides, cloud_requirements, Arg0DispatchPaths::default(), Arc::new(codex_config::NoopThreadConfigLoader), - host_name, ) } @@ -286,7 +278,6 @@ impl ConfigManager { Vec::new(), LoaderOverrides::without_managed_config_for_tests(), CloudRequirementsLoader::default(), - /*host_name*/ None, ) } } diff --git a/codex-rs/app-server/src/config_manager_service.rs b/codex-rs/app-server/src/config_manager_service.rs index 0104429a4b..ec4a1a6803 100644 --- a/codex-rs/app-server/src/config_manager_service.rs +++ b/codex-rs/app-server/src/config_manager_service.rs @@ -12,16 +12,16 @@ use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::OverriddenMetadata; use codex_app_server_protocol::WriteStatus; use codex_config::CONFIG_TOML_FILE; +use codex_config::ConfigLayerEntry; +use codex_config::ConfigLayerStack; +use codex_config::ConfigLayerStackOrdering; +use codex_config::ConfigRequirementsToml; use codex_config::config_toml::ConfigToml; +use codex_config::merge_toml_values; use codex_core::config::deserialize_config_toml_with_base; use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config::validate_feature_requirements_for_config_toml; -use codex_core::config_loader::ConfigLayerEntry; -use codex_core::config_loader::ConfigLayerStack; -use codex_core::config_loader::ConfigLayerStackOrdering; -use codex_core::config_loader::ConfigRequirementsToml; -use codex_core::config_loader::merge_toml_values; use codex_core::path_utils; use codex_core::path_utils::SymlinkWritePaths; use codex_core::path_utils::resolve_symlink_write_paths; diff --git a/codex-rs/app-server/src/config_manager_service_tests.rs b/codex-rs/app-server/src/config_manager_service_tests.rs index a871d8e43f..108254859d 100644 --- a/codex-rs/app-server/src/config_manager_service_tests.rs +++ b/codex-rs/app-server/src/config_manager_service_tests.rs @@ -4,9 +4,9 @@ use codex_app_server_protocol::AppConfig; use codex_app_server_protocol::AppToolApproval; use codex_app_server_protocol::AppsConfig; use codex_app_server_protocol::AskForApproval; -use codex_core::config_loader::CloudRequirementsLoader; -use codex_core::config_loader::FeatureRequirementsToml; -use codex_core::config_loader::LoaderOverrides; +use codex_config::CloudRequirementsLoader; +use codex_config::FeatureRequirementsToml; +use codex_config::LoaderOverrides; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::collections::BTreeMap; @@ -226,7 +226,6 @@ async fn read_includes_origins_and_layers() { vec![], LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), CloudRequirementsLoader::default(), - /*host_name*/ None, ); let response = service @@ -305,7 +304,6 @@ writable_roots = ["~/code"] vec![], loader_overrides, CloudRequirementsLoader::default(), - /*host_name*/ None, ); let response = service @@ -346,7 +344,6 @@ async fn write_value_reports_override() { vec![], LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), CloudRequirementsLoader::default(), - /*host_name*/ None, ); let result = service @@ -446,7 +443,6 @@ async fn invalid_user_value_rejected_even_if_overridden_by_managed() { vec![], LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), CloudRequirementsLoader::default(), - /*host_name*/ None, ); let error = service @@ -514,7 +510,6 @@ async fn write_value_rejects_feature_requirement_conflict() { ..Default::default() })) }), - /*host_name*/ None, ); let error = service @@ -561,7 +556,6 @@ async fn write_value_rejects_profile_feature_requirement_conflict() { ..Default::default() })) }), - /*host_name*/ None, ); let error = service @@ -612,7 +606,6 @@ async fn read_reports_managed_overrides_user_and_session_flags() { cli_overrides, LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), CloudRequirementsLoader::default(), - /*host_name*/ None, ); let response = service @@ -666,7 +659,6 @@ async fn write_value_reports_managed_override() { vec![], LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), CloudRequirementsLoader::default(), - /*host_name*/ None, ); let result = service diff --git a/codex-rs/app-server/src/device_key_api.rs b/codex-rs/app-server/src/device_key_api.rs index beead123b0..b3d31426d1 100644 --- a/codex-rs/app-server/src/device_key_api.rs +++ b/codex-rs/app-server/src/device_key_api.rs @@ -1,5 +1,6 @@ -use crate::error_code::INTERNAL_ERROR_CODE; -use crate::error_code::INVALID_REQUEST_ERROR_CODE; +use crate::error_code::internal_error; +use crate::error_code::invalid_request; +use async_trait::async_trait; use base64::Engine; use base64::engine::general_purpose::STANDARD; use codex_app_server_protocol::DeviceKeyAlgorithm; @@ -13,6 +14,7 @@ use codex_app_server_protocol::DeviceKeySignPayload; use codex_app_server_protocol::DeviceKeySignResponse; use codex_app_server_protocol::JSONRPCErrorError; use codex_device_key::DeviceKeyBinding; +use codex_device_key::DeviceKeyBindingStore; use codex_device_key::DeviceKeyCreateRequest; use codex_device_key::DeviceKeyError; use codex_device_key::DeviceKeyGetPublicRequest; @@ -24,14 +26,29 @@ use codex_device_key::RemoteControlClientConnectionAudience; use codex_device_key::RemoteControlClientConnectionSignPayload; use codex_device_key::RemoteControlClientEnrollmentAudience; use codex_device_key::RemoteControlClientEnrollmentSignPayload; +use codex_state::DeviceKeyBindingRecord; +use codex_state::StateRuntime; +use std::fmt; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::OnceCell; -#[derive(Clone, Default)] +#[derive(Clone)] pub(crate) struct DeviceKeyApi { store: DeviceKeyStore, } impl DeviceKeyApi { - pub(crate) fn create( + pub(crate) fn new(sqlite_home: PathBuf, default_provider: String) -> Self { + Self { + store: DeviceKeyStore::new(Arc::new(StateDeviceKeyBindingStore::new( + sqlite_home, + default_provider, + ))), + } + } + + pub(crate) async fn create( &self, params: DeviceKeyCreateParams, ) -> Result { @@ -44,11 +61,12 @@ impl DeviceKeyApi { client_id: params.client_id, }, }) + .await .map_err(map_device_key_error)?; Ok(create_response_from_info(info)) } - pub(crate) fn public( + pub(crate) async fn public( &self, params: DeviceKeyPublicParams, ) -> Result { @@ -57,11 +75,12 @@ impl DeviceKeyApi { .get_public(DeviceKeyGetPublicRequest { key_id: params.key_id, }) + .await .map_err(map_device_key_error)?; Ok(public_response_from_info(info)) } - pub(crate) fn sign( + pub(crate) async fn sign( &self, params: DeviceKeySignParams, ) -> Result { @@ -71,6 +90,7 @@ impl DeviceKeyApi { key_id: params.key_id, payload: payload_from_params(params.payload), }) + .await .map_err(map_device_key_error)?; Ok(DeviceKeySignResponse { signature_der_base64: STANDARD.encode(signature.signature_der), @@ -80,6 +100,77 @@ impl DeviceKeyApi { } } +struct StateDeviceKeyBindingStore { + sqlite_home: PathBuf, + default_provider: String, + state_db: OnceCell>, +} + +impl StateDeviceKeyBindingStore { + fn new(sqlite_home: PathBuf, default_provider: String) -> Self { + Self { + sqlite_home, + default_provider, + state_db: OnceCell::new(), + } + } + + async fn state_db(&self) -> Result, DeviceKeyError> { + let sqlite_home = self.sqlite_home.clone(); + let default_provider = self.default_provider.clone(); + self.state_db + .get_or_try_init(|| async move { + StateRuntime::init(sqlite_home, default_provider) + .await + .map_err(|err| DeviceKeyError::Platform(err.to_string())) + }) + .await + .cloned() + } +} + +impl fmt::Debug for StateDeviceKeyBindingStore { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("StateDeviceKeyBindingStore") + .field("sqlite_home", &self.sqlite_home) + .field("default_provider", &self.default_provider) + .finish_non_exhaustive() + } +} + +#[async_trait] +impl DeviceKeyBindingStore for StateDeviceKeyBindingStore { + async fn get_binding(&self, key_id: &str) -> Result, DeviceKeyError> { + let state_db = self.state_db().await?; + state_db + .get_device_key_binding(key_id) + .await + .map(|record| { + record.map(|record| DeviceKeyBinding { + account_user_id: record.account_user_id, + client_id: record.client_id, + }) + }) + .map_err(|err| DeviceKeyError::Platform(err.to_string())) + } + + async fn put_binding( + &self, + key_id: &str, + binding: &DeviceKeyBinding, + ) -> Result<(), DeviceKeyError> { + let state_db = self.state_db().await?; + state_db + .upsert_device_key_binding(&DeviceKeyBindingRecord { + key_id: key_id.to_string(), + account_user_id: binding.account_user_id.clone(), + client_id: binding.client_id.clone(), + }) + .await + .map_err(|err| DeviceKeyError::Platform(err.to_string())) + } +} + fn create_response_from_info(info: DeviceKeyInfo) -> DeviceKeyCreateResponse { DeviceKeyCreateResponse { key_id: info.key_id, @@ -211,16 +302,13 @@ fn protection_class_from_store( } fn map_device_key_error(error: DeviceKeyError) -> JSONRPCErrorError { - let code = match error { + match &error { DeviceKeyError::DegradedProtectionNotAllowed { .. } | DeviceKeyError::HardwareBackedKeysUnavailable | DeviceKeyError::KeyNotFound - | DeviceKeyError::InvalidPayload(_) => INVALID_REQUEST_ERROR_CODE, - DeviceKeyError::Platform(_) | DeviceKeyError::Crypto(_) => INTERNAL_ERROR_CODE, - }; - JSONRPCErrorError { - code, - message: error.to_string(), - data: None, + | DeviceKeyError::InvalidPayload(_) => invalid_request(error.to_string()), + DeviceKeyError::Platform(_) | DeviceKeyError::Crypto(_) => { + internal_error(error.to_string()) + } } } diff --git a/codex-rs/app-server/src/error_code.rs b/codex-rs/app-server/src/error_code.rs index 924a7086ae..0054d2988f 100644 --- a/codex-rs/app-server/src/error_code.rs +++ b/codex-rs/app-server/src/error_code.rs @@ -1,5 +1,27 @@ +use codex_app_server_protocol::JSONRPCErrorError; + pub(crate) const INVALID_REQUEST_ERROR_CODE: i64 = -32600; pub const INVALID_PARAMS_ERROR_CODE: i64 = -32602; pub(crate) const INTERNAL_ERROR_CODE: i64 = -32603; pub(crate) const OVERLOADED_ERROR_CODE: i64 = -32001; pub const INPUT_TOO_LARGE_ERROR_CODE: &str = "input_too_large"; + +pub(crate) fn invalid_request(message: impl Into) -> JSONRPCErrorError { + error(INVALID_REQUEST_ERROR_CODE, message) +} + +pub(crate) fn invalid_params(message: impl Into) -> JSONRPCErrorError { + error(INVALID_PARAMS_ERROR_CODE, message) +} + +pub(crate) fn internal_error(message: impl Into) -> JSONRPCErrorError { + error(INTERNAL_ERROR_CODE, message) +} + +fn error(code: i64, message: impl Into) -> JSONRPCErrorError { + JSONRPCErrorError { + code, + message: message.into(), + data: None, + } +} diff --git a/codex-rs/app-server/src/external_agent_config_api.rs b/codex-rs/app-server/src/external_agent_config_api.rs index 0741ad5bd8..34ad572caf 100644 --- a/codex-rs/app-server/src/external_agent_config_api.rs +++ b/codex-rs/app-server/src/external_agent_config_api.rs @@ -3,7 +3,7 @@ use crate::config::external_agent_config::ExternalAgentConfigMigrationItem as Co use crate::config::external_agent_config::ExternalAgentConfigMigrationItemType as CoreMigrationItemType; use crate::config::external_agent_config::ExternalAgentConfigService; use crate::config::external_agent_config::PendingPluginImport; -use crate::error_code::INTERNAL_ERROR_CODE; +use crate::error_code::internal_error; use codex_app_server_protocol::ExternalAgentConfigDetectParams; use codex_app_server_protocol::ExternalAgentConfigDetectResponse; use codex_app_server_protocol::ExternalAgentConfigImportParams; @@ -12,7 +12,6 @@ use codex_app_server_protocol::ExternalAgentConfigMigrationItemType; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::MigrationDetails; use codex_app_server_protocol::PluginsMigration; -use std::io; use std::path::PathBuf; #[derive(Clone)] @@ -38,7 +37,7 @@ impl ExternalAgentConfigApi { cwds: params.cwds, }) .await - .map_err(map_io_error)?; + .map_err(|err| internal_error(err.to_string()))?; Ok(ExternalAgentConfigDetectResponse { items: items @@ -125,7 +124,7 @@ impl ExternalAgentConfigApi { .collect(), ) .await - .map_err(map_io_error) + .map_err(|err| internal_error(err.to_string())) } pub(crate) async fn complete_pending_plugin_import( @@ -139,14 +138,6 @@ impl ExternalAgentConfigApi { ) .await .map(|_| ()) - .map_err(map_io_error) - } -} - -fn map_io_error(err: io::Error) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: err.to_string(), - data: None, + .map_err(|err| internal_error(err.to_string())) } } diff --git a/codex-rs/app-server/src/fs_api.rs b/codex-rs/app-server/src/fs_api.rs index 93b4f21c2b..203b053e5e 100644 --- a/codex-rs/app-server/src/fs_api.rs +++ b/codex-rs/app-server/src/fs_api.rs @@ -1,5 +1,5 @@ -use crate::error_code::INTERNAL_ERROR_CODE; -use crate::error_code::INVALID_REQUEST_ERROR_CODE; +use crate::error_code::internal_error; +use crate::error_code::invalid_request; use base64::Engine; use base64::engine::general_purpose::STANDARD; use codex_app_server_protocol::FsCopyParams; @@ -158,22 +158,10 @@ impl FsApi { } } -pub(crate) fn invalid_request(message: impl Into) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: message.into(), - data: None, - } -} - pub(crate) fn map_fs_error(err: io::Error) -> JSONRPCErrorError { if err.kind() == io::ErrorKind::InvalidInput { invalid_request(err.to_string()) } else { - JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: err.to_string(), - data: None, - } + internal_error(err.to_string()) } } diff --git a/codex-rs/app-server/src/fs_watch.rs b/codex-rs/app-server/src/fs_watch.rs index ff00051472..4ae1ca149e 100644 --- a/codex-rs/app-server/src/fs_watch.rs +++ b/codex-rs/app-server/src/fs_watch.rs @@ -1,4 +1,4 @@ -use crate::fs_api::invalid_request; +use crate::error_code::invalid_request; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::OutgoingMessageSender; use codex_app_server_protocol::FsChangedNotification; diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 729f6d04af..73332394f4 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -77,10 +77,10 @@ use codex_app_server_protocol::Result; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_arg0::Arg0DispatchPaths; +use codex_config::CloudRequirementsLoader; +use codex_config::LoaderOverrides; use codex_config::ThreadConfigLoader; use codex_core::config::Config; -use codex_core::config_loader::CloudRequirementsLoader; -use codex_core::config_loader::LoaderOverrides; use codex_exec_server::EnvironmentManager; use codex_feedback::CodexFeedback; use codex_login::AuthManager; @@ -391,7 +391,8 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { let processor_outgoing = Arc::clone(&outgoing_message_sender); let auth_manager = - AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env); + AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env) + .await; let config_manager = ConfigManager::new( args.config.codex_home.to_path_buf(), args.cli_overrides, @@ -415,6 +416,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { auth_manager, rpc_transport: AppServerRpcTransport::InProcess, remote_control_handle: None, + plugin_startup_tasks: crate::PluginStartupTasks::Start, })); let mut thread_created_rx = processor.thread_created_receiver(); let session = Arc::new(ConnectionSessionState::new(ConnectionOrigin::InProcess)); diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index a2f35305ae..de89608101 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -1,11 +1,12 @@ #![deny(clippy::print_stdout, clippy::print_stderr)] use codex_arg0::Arg0DispatchPaths; +use codex_config::ConfigLayerStackOrdering; +use codex_config::LoaderOverrides; use codex_config::NoopThreadConfigLoader; +use codex_config::RemoteThreadConfigLoader; use codex_config::ThreadConfigLoader; use codex_core::config::Config; -use codex_core::config_loader::ConfigLayerStackOrdering; -use codex_core::config_loader::LoaderOverrides; use codex_exec_server::EnvironmentManagerArgs; use codex_features::Feature; use codex_login::AuthManager; @@ -31,6 +32,7 @@ use crate::transport::OutboundConnectionState; use crate::transport::TransportEvent; use crate::transport::auth::policy_from_settings; use crate::transport::route_outgoing_envelope; +use crate::transport::start_control_socket_acceptor; use crate::transport::start_remote_control; use crate::transport::start_stdio_connection; use crate::transport::start_websocket_acceptor; @@ -40,11 +42,11 @@ use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::TextPosition as AppTextPosition; use codex_app_server_protocol::TextRange as AppTextRange; +use codex_config::ConfigLoadError; +use codex_config::TextRange as CoreTextRange; use codex_core::ExecPolicyError; use codex_core::check_execpolicy_for_warnings; use codex_core::config::find_codex_home; -use codex_core::config_loader::ConfigLoadError; -use codex_core::config_loader::TextRange as CoreTextRange; use codex_exec_server::EnvironmentManager; use codex_exec_server::ExecServerRuntimePaths; use codex_feedback::CodexFeedback; @@ -93,6 +95,7 @@ mod transport; pub use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE; pub use crate::error_code::INVALID_PARAMS_ERROR_CODE; pub use crate::transport::AppServerTransport; +pub use crate::transport::app_server_control_socket_path; pub use crate::transport::auth::AppServerWebsocketAuthArgs; pub use crate::transport::auth::AppServerWebsocketAuthSettings; pub use crate::transport::auth::WebsocketAuthCliMode; @@ -107,6 +110,13 @@ enum LogFormat { type StderrLogLayer = Box + Send + Sync + 'static>; +fn configured_thread_config_loader(config: &Config) -> Arc { + match config.experimental_thread_config_endpoint.as_deref() { + Some(endpoint) => Arc::new(RemoteThreadConfigLoader::new(endpoint)), + None => Arc::new(NoopThreadConfigLoader), + } +} + /// Control-plane messages from the processor/transport side to the outbound router task. /// /// `run_main_with_transport` now uses two loops/tasks: @@ -352,6 +362,25 @@ pub async fn run_main( .await } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PluginStartupTasks { + Start, + Skip, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AppServerRuntimeOptions { + pub plugin_startup_tasks: PluginStartupTasks, +} + +impl Default for AppServerRuntimeOptions { + fn default() -> Self { + Self { + plugin_startup_tasks: PluginStartupTasks::Start, + } + } +} + pub async fn run_main_with_transport( arg0_paths: Arg0DispatchPaths, cli_config_overrides: CliConfigOverrides, @@ -360,6 +389,30 @@ pub async fn run_main_with_transport( transport: AppServerTransport, session_source: SessionSource, auth: AppServerWebsocketAuthSettings, +) -> IoResult<()> { + run_main_with_transport_options( + arg0_paths, + cli_config_overrides, + loader_overrides, + default_analytics_enabled, + transport, + session_source, + auth, + AppServerRuntimeOptions::default(), + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub async fn run_main_with_transport_options( + arg0_paths: Arg0DispatchPaths, + cli_config_overrides: CliConfigOverrides, + loader_overrides: LoaderOverrides, + default_analytics_enabled: bool, + transport: AppServerTransport, + session_source: SessionSource, + auth: AppServerWebsocketAuthSettings, + runtime_options: AppServerRuntimeOptions, ) -> IoResult<()> { let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::from_env( ExecServerRuntimePaths::from_optional_paths( @@ -382,14 +435,13 @@ pub async fn run_main_with_transport( ) })?; let codex_home = find_codex_home()?; - let thread_config_loader: Arc = Arc::new(NoopThreadConfigLoader); let config_manager = ConfigManager::new( codex_home.to_path_buf(), cli_kv_overrides.clone(), loader_overrides, Default::default(), arg0_paths.clone(), - thread_config_loader.clone(), + Arc::new(NoopThreadConfigLoader), ); match config_manager .load_latest_config(/*fallback_cwd*/ None) @@ -413,8 +465,11 @@ pub async fn run_main_with_transport( } } + let discovered_thread_config_loader = configured_thread_config_loader(&config); + config_manager + .replace_thread_config_loader(Arc::clone(&discovered_thread_config_loader)); let auth_manager = - AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false); + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; config_manager.replace_cloud_requirements_loader(auth_manager, config.chatgpt_base_url); } Err(err) => { @@ -463,7 +518,7 @@ pub async fn run_main_with_transport( }); } if let Some(warning) = - codex_core::config::system_bwrap_warning(config.permissions.sandbox_policy.get()) + codex_core::config::system_bwrap_warning(config.permissions.permission_profile.get()) { config_warnings.push(ConfigWarningNotification { summary: warning, @@ -542,7 +597,7 @@ pub async fn run_main_with_transport( let graceful_signal_restart_enabled = !single_client_mode; let mut app_server_client_name_rx = None; - match transport { + match &transport { AppServerTransport::Stdio => { let (stdio_client_name_tx, stdio_client_name_rx) = oneshot::channel::(); app_server_client_name_rx = Some(stdio_client_name_rx); @@ -553,9 +608,18 @@ pub async fn run_main_with_transport( ) .await?; } + AppServerTransport::UnixSocket { socket_path } => { + let accept_handle = start_control_socket_acceptor( + socket_path.clone(), + transport_event_tx.clone(), + transport_shutdown_token.clone(), + ) + .await?; + transport_accept_handles.push(accept_handle); + } AppServerTransport::WebSocket { bind_address } => { let accept_handle = start_websocket_acceptor( - bind_address, + *bind_address, transport_event_tx.clone(), transport_shutdown_token.clone(), policy_from_settings(&auth)?, @@ -567,7 +631,7 @@ pub async fn run_main_with_transport( } let auth_manager = - AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false); + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; let remote_control_enabled = config.features.enabled(Feature::RemoteControl); if transport_accept_handles.is_empty() && !remote_control_enabled { @@ -648,7 +712,7 @@ pub async fn run_main_with_transport( let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(outgoing_tx)); let outbound_control_tx = outbound_control_tx; let auth_manager = - AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false); + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; let processor = Arc::new(MessageProcessor::new(MessageProcessorArgs { outgoing: outgoing_message_sender, arg0_paths, @@ -660,8 +724,9 @@ pub async fn run_main_with_transport( config_warnings, session_source, auth_manager, - rpc_transport: analytics_rpc_transport(transport), + rpc_transport: analytics_rpc_transport(&transport), remote_control_handle: Some(remote_control_handle), + plugin_startup_tasks: runtime_options.plugin_startup_tasks, })); let mut thread_created_rx = processor.thread_created_receiver(); let mut running_turn_count_rx = processor.subscribe_running_assistant_turn_count(); @@ -772,7 +837,7 @@ pub async fn run_main_with_transport( .process_request( connection_id, request, - transport, + &transport, Arc::clone(&connection_state.session), ) .await; @@ -892,12 +957,12 @@ pub async fn run_main_with_transport( Ok(()) } -fn analytics_rpc_transport(transport: AppServerTransport) -> AppServerRpcTransport { +fn analytics_rpc_transport(transport: &AppServerTransport) -> AppServerRpcTransport { match transport { AppServerTransport::Stdio => AppServerRpcTransport::Stdio, - AppServerTransport::WebSocket { .. } | AppServerTransport::Off => { - AppServerRpcTransport::Websocket - } + AppServerTransport::UnixSocket { .. } + | AppServerTransport::WebSocket { .. } + | AppServerTransport::Off => AppServerRpcTransport::Websocket, } } diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 069227070e..1cb4bd9a8e 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -1,10 +1,12 @@ use clap::Parser; +use codex_app_server::AppServerRuntimeOptions; use codex_app_server::AppServerTransport; use codex_app_server::AppServerWebsocketAuthArgs; -use codex_app_server::run_main_with_transport; +use codex_app_server::PluginStartupTasks; +use codex_app_server::run_main_with_transport_options; use codex_arg0::Arg0DispatchPaths; use codex_arg0::arg0_dispatch_or_else; -use codex_core::config_loader::LoaderOverrides; +use codex_config::LoaderOverrides; use codex_protocol::protocol::SessionSource; use codex_utils_cli::CliConfigOverrides; use std::path::PathBuf; @@ -17,7 +19,7 @@ const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_C #[derive(Debug, Parser)] struct AppServerArgs { /// Transport endpoint URL. Supported values: `stdio://` (default), - /// `ws://IP:PORT`, `off`. + /// `unix://`, `unix://PATH`, `ws://IP:PORT`, `off`. #[arg( long = "listen", value_name = "URL", @@ -36,6 +38,12 @@ struct AppServerArgs { #[command(flatten)] auth: AppServerWebsocketAuthArgs, + + /// Hidden debug-only test hook used by integration tests that spawn the + /// production app-server binary. + #[cfg(debug_assertions)] + #[arg(long = "disable-plugin-startup-tasks-for-tests", hide = true)] + disable_plugin_startup_tasks_for_tests: bool, } fn main() -> anyhow::Result<()> { @@ -51,8 +59,13 @@ fn main() -> anyhow::Result<()> { let transport = args.listen; let session_source = args.session_source; let auth = args.auth.try_into_settings()?; + let mut runtime_options = AppServerRuntimeOptions::default(); + #[cfg(debug_assertions)] + if args.disable_plugin_startup_tasks_for_tests { + runtime_options.plugin_startup_tasks = PluginStartupTasks::Skip; + } - run_main_with_transport( + run_main_with_transport_options( arg0_paths, CliConfigOverrides::default(), loader_overrides, @@ -60,6 +73,7 @@ fn main() -> anyhow::Result<()> { transport, session_source, auth, + runtime_options, ) .await?; Ok(()) diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 57fa6e21e0..071f69eb6a 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -10,7 +10,7 @@ use crate::codex_message_processor::CodexMessageProcessorArgs; use crate::config_api::ConfigApi; use crate::config_manager::ConfigManager; use crate::device_key_api::DeviceKeyApi; -use crate::error_code::INVALID_REQUEST_ERROR_CODE; +use crate::error_code::invalid_request; use crate::external_agent_config_api::ExternalAgentConfigApi; use crate::fs_api::FsApi; use crate::fs_watch::FsWatchManager; @@ -34,7 +34,6 @@ use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientNotification; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigBatchWriteParams; -use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::DeviceKeyCreateParams; @@ -42,20 +41,10 @@ use codex_app_server_protocol::DeviceKeyPublicParams; use codex_app_server_protocol::DeviceKeySignParams; use codex_app_server_protocol::ExperimentalApi; use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; -use codex_app_server_protocol::ExternalAgentConfigDetectParams; use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification; use codex_app_server_protocol::ExternalAgentConfigImportParams; use codex_app_server_protocol::ExternalAgentConfigImportResponse; use codex_app_server_protocol::ExternalAgentConfigMigrationItemType; -use codex_app_server_protocol::FsCopyParams; -use codex_app_server_protocol::FsCreateDirectoryParams; -use codex_app_server_protocol::FsGetMetadataParams; -use codex_app_server_protocol::FsReadDirectoryParams; -use codex_app_server_protocol::FsReadFileParams; -use codex_app_server_protocol::FsRemoveParams; -use codex_app_server_protocol::FsUnwatchParams; -use codex_app_server_protocol::FsWatchParams; -use codex_app_server_protocol::FsWriteFileParams; use codex_app_server_protocol::InitializeResponse; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCErrorError; @@ -95,7 +84,6 @@ use tokio::time::timeout; use tracing::Instrument; const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); - #[derive(Clone)] struct ExternalAuthRefreshBridge { outgoing: Arc, @@ -260,6 +248,7 @@ pub(crate) struct MessageProcessorArgs { pub(crate) auth_manager: Arc, pub(crate) rpc_transport: AppServerRpcTransport, pub(crate) remote_control_handle: Option, + pub(crate) plugin_startup_tasks: crate::PluginStartupTasks, } impl MessageProcessor { @@ -279,6 +268,7 @@ impl MessageProcessor { auth_manager, rpc_transport, remote_control_handle, + plugin_startup_tasks, } = args; auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), @@ -315,17 +305,20 @@ impl MessageProcessor { feedback, log_db, }); - // Keep plugin startup warmups aligned at app-server startup. - // TODO(xl): Move into PluginManager once this no longer depends on config feature gating. - thread_manager - .plugins_manager() - .maybe_start_plugin_startup_tasks_for_config(&config, auth_manager.clone()); + if matches!(plugin_startup_tasks, crate::PluginStartupTasks::Start) { + // Keep plugin startup warmups aligned at app-server startup. + // TODO(xl): Move into PluginManager once this no longer depends on config feature gating. + thread_manager + .plugins_manager() + .maybe_start_plugin_startup_tasks_for_config(&config, auth_manager.clone()); + } let config_api = ConfigApi::new( config_manager, thread_manager.clone(), analytics_events_client.clone(), ); - let device_key_api = DeviceKeyApi::default(); + let device_key_api = + DeviceKeyApi::new(config.sqlite_home.clone(), config.model_provider_id.clone()); let external_agent_config_api = ExternalAgentConfigApi::new(config.codex_home.to_path_buf()); let fs_api = FsApi::new( @@ -362,7 +355,7 @@ impl MessageProcessor { self: &Arc, connection_id: ConnectionId, request: JSONRPCRequest, - transport: AppServerTransport, + transport: &AppServerTransport, session: Arc, ) { let request_method = request.method.as_str(); @@ -386,43 +379,28 @@ impl MessageProcessor { Arc::clone(&self.outgoing), request_context.clone(), async { - let request_json = match serde_json::to_value(&request) { - Ok(request_json) => request_json, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("Invalid request: {err}"), - data: None, - }; - self.outgoing.send_error(request_id.clone(), error).await; - return; - } - }; - - let codex_request = match serde_json::from_value::(request_json) { - Ok(codex_request) => codex_request, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("Invalid request: {err}"), - data: None, - }; - self.outgoing.send_error(request_id.clone(), error).await; - return; - } - }; - // Websocket callers finalize outbound readiness in lib.rs after mirroring - // session state into outbound state and sending initialize notifications to - // this specific connection. Passing `None` avoids marking the connection - // ready too early from inside the shared request handler. - self.handle_client_request( - request_id.clone(), - codex_request, - Arc::clone(&session), - /*outbound_initialized*/ None, - request_context.clone(), - ) + let result = async { + let request_json = serde_json::to_value(&request) + .map_err(|err| invalid_request(format!("Invalid request: {err}")))?; + let codex_request = serde_json::from_value::(request_json) + .map_err(|err| invalid_request(format!("Invalid request: {err}")))?; + // Websocket callers finalize outbound readiness in lib.rs after mirroring + // session state into outbound state and sending initialize notifications to + // this specific connection. Passing `None` avoids marking the connection + // ready too early from inside the shared request handler. + self.handle_client_request( + request_id.clone(), + codex_request, + Arc::clone(&session), + /*outbound_initialized*/ None, + request_context.clone(), + ) + .await + } .await; + if let Err(error) = result { + self.outgoing.send_error(request_id.clone(), error).await; + } }, ) .await; @@ -459,14 +437,18 @@ impl MessageProcessor { // In-process clients do not have the websocket transport loop that performs // post-initialize bookkeeping, so they still finalize outbound readiness in // the shared request handler. - self.handle_client_request( - request_id.clone(), - request, - Arc::clone(&session), - Some(outbound_initialized), - request_context.clone(), - ) - .await; + let result = self + .handle_client_request( + request_id.clone(), + request, + Arc::clone(&session), + Some(outbound_initialized), + request_context.clone(), + ) + .await; + if let Err(error) = result { + self.outgoing.send_error(request_id.clone(), error).await; + } }, ) .await; @@ -594,7 +576,7 @@ impl MessageProcessor { // lib.rs can deliver connection-scoped initialize notifications first. outbound_initialized: Option<&AtomicBool>, request_context: RequestContext, - ) { + ) -> Result<(), JSONRPCErrorError> { let connection_id = connection_request_id.connection_id; if let ClientRequest::Initialize { request_id, params } = codex_request { // Handle Initialize internally so CodexMessageProcessor does not have to concern @@ -604,13 +586,7 @@ impl MessageProcessor { request_id, }; if session.initialized() { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "Already initialized".to_string(), - data: None, - }; - self.outgoing.send_error(connection_request_id, error).await; - return; + return Err(invalid_request("Already initialized")); } // TODO(maxj): Revisit capability scoping for `experimental_api_enabled`. @@ -638,17 +614,9 @@ impl MessageProcessor { // Validate before committing; set_default_originator validates while // mutating process-global metadata. if HeaderValue::from_str(&name).is_err() { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!( - "Invalid clientInfo.name: '{name}'. Must be a valid HTTP header value." - ), - data: None, - }; - self.outgoing - .send_error(connection_request_id.clone(), error) - .await; - return; + return Err(invalid_request(format!( + "Invalid clientInfo.name: '{name}'. Must be a valid HTTP header value." + ))); } let originator = name.clone(); let user_agent_suffix = format!("{name}; {version}"); @@ -664,13 +632,7 @@ impl MessageProcessor { }) .is_err() { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "Already initialized".to_string(), - data: None, - }; - self.outgoing.send_error(connection_request_id, error).await; - return; + return Err(invalid_request("Already initialized")); } // Only the request that wins session initialization may mutate @@ -691,14 +653,12 @@ impl MessageProcessor { } } } - if self.config.features.enabled(Feature::GeneralAnalytics) { - self.analytics_events_client.track_initialize( - connection_id.0, - analytics_initialize_params, - originator, - self.rpc_transport, - ); - } + self.analytics_events_client.track_initialize( + connection_id.0, + analytics_initialize_params, + originator, + self.rpc_transport, + ); set_default_client_residency_requirement(self.config.enforce_residency.value()); if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() { *suffix = Some(user_agent_suffix); @@ -725,7 +685,7 @@ impl MessageProcessor { .connection_initialized(connection_id) .await; } - return; + return Ok(()); } self.dispatch_initialized_client_request( @@ -734,7 +694,7 @@ impl MessageProcessor { session, request_context, ) - .await; + .await } async fn dispatch_initialized_client_request( @@ -743,32 +703,19 @@ impl MessageProcessor { codex_request: ClientRequest, session: Arc, request_context: RequestContext, - ) { + ) -> Result<(), JSONRPCErrorError> { if !session.initialized() { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "Not initialized".to_string(), - data: None, - }; - self.outgoing.send_error(connection_request_id, error).await; - return; + return Err(invalid_request("Not initialized")); } if let Some(reason) = codex_request.experimental_reason() && !session.experimental_api_enabled() { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: experimental_required_message(reason), - data: None, - }; - self.outgoing.send_error(connection_request_id, error).await; - return; + return Err(invalid_request(experimental_required_message(reason))); } let connection_id = connection_request_id.connection_id; - if self.config.features.enabled(Feature::GeneralAnalytics) - && let ClientRequest::TurnStart { request_id, .. } - | ClientRequest::TurnSteer { request_id, .. } = &codex_request + if let ClientRequest::TurnStart { request_id, .. } + | ClientRequest::TurnSteer { request_id, .. } = &codex_request { self.analytics_events_client.track_request( connection_id.0, @@ -789,7 +736,7 @@ impl MessageProcessor { client_version, device_key_requests_allowed, ) - .await; + .await } async fn handle_initialized_client_request( @@ -800,66 +747,48 @@ impl MessageProcessor { app_server_client_name: Option, client_version: Option, device_key_requests_allowed: bool, - ) { + ) -> Result<(), JSONRPCErrorError> { let connection_id = connection_request_id.connection_id; + let request_id_for_connection = |request_id| ConnectionRequestId { + connection_id, + request_id, + }; match codex_request { ClientRequest::ConfigRead { request_id, params } => { - self.handle_config_read( - ConnectionRequestId { - connection_id, - request_id, - }, - params, - ) - .await; + self.outgoing + .send_result( + request_id_for_connection(request_id), + self.config_api.read(params).await, + ) + .await; } ClientRequest::ExternalAgentConfigDetect { request_id, params } => { - self.handle_external_agent_config_detect( - ConnectionRequestId { - connection_id, - request_id, - }, - params, - ) - .await; + self.outgoing + .send_result( + request_id_for_connection(request_id), + self.external_agent_config_api.detect(params).await, + ) + .await; } ClientRequest::ExternalAgentConfigImport { request_id, params } => { self.handle_external_agent_config_import( - ConnectionRequestId { - connection_id, - request_id, - }, + request_id_for_connection(request_id), params, ) - .await; + .await?; } ClientRequest::ConfigValueWrite { request_id, params } => { - self.handle_config_value_write( - ConnectionRequestId { - connection_id, - request_id, - }, - params, - ) - .await; + self.handle_config_value_write(request_id_for_connection(request_id), params) + .await; } ClientRequest::ConfigBatchWrite { request_id, params } => { - self.handle_config_batch_write( - ConnectionRequestId { - connection_id, - request_id, - }, - params, - ) - .await; + self.handle_config_batch_write(request_id_for_connection(request_id), params) + .await; } ClientRequest::ExperimentalFeatureEnablementSet { request_id, params } => { self.handle_experimental_feature_enablement_set( - ConnectionRequestId { - connection_id, - request_id, - }, + request_id_for_connection(request_id), params, ) .await; @@ -868,136 +797,105 @@ impl MessageProcessor { request_id, params: _, } => { - self.handle_config_requirements_read(ConnectionRequestId { - connection_id, - request_id, - }) - .await; + self.outgoing + .send_result( + request_id_for_connection(request_id), + self.config_api.config_requirements_read().await, + ) + .await; } ClientRequest::DeviceKeyCreate { request_id, params } => { self.handle_device_key_create( - ConnectionRequestId { - connection_id, - request_id, - }, + request_id_for_connection(request_id), params, device_key_requests_allowed, - ) - .await; + ); } ClientRequest::DeviceKeyPublic { request_id, params } => { self.handle_device_key_public( - ConnectionRequestId { - connection_id, - request_id, - }, + request_id_for_connection(request_id), params, device_key_requests_allowed, - ) - .await; + ); } ClientRequest::DeviceKeySign { request_id, params } => { self.handle_device_key_sign( - ConnectionRequestId { - connection_id, - request_id, - }, + request_id_for_connection(request_id), params, device_key_requests_allowed, - ) - .await; + ); } ClientRequest::FsReadFile { request_id, params } => { - self.handle_fs_read_file( - ConnectionRequestId { - connection_id, - request_id, - }, - params, - ) - .await; + self.outgoing + .send_result( + request_id_for_connection(request_id), + self.fs_api.read_file(params).await, + ) + .await; } ClientRequest::FsWriteFile { request_id, params } => { - self.handle_fs_write_file( - ConnectionRequestId { - connection_id, - request_id, - }, - params, - ) - .await; + self.outgoing + .send_result( + request_id_for_connection(request_id), + self.fs_api.write_file(params).await, + ) + .await; } ClientRequest::FsCreateDirectory { request_id, params } => { - self.handle_fs_create_directory( - ConnectionRequestId { - connection_id, - request_id, - }, - params, - ) - .await; + self.outgoing + .send_result( + request_id_for_connection(request_id), + self.fs_api.create_directory(params).await, + ) + .await; } ClientRequest::FsGetMetadata { request_id, params } => { - self.handle_fs_get_metadata( - ConnectionRequestId { - connection_id, - request_id, - }, - params, - ) - .await; + self.outgoing + .send_result( + request_id_for_connection(request_id), + self.fs_api.get_metadata(params).await, + ) + .await; } ClientRequest::FsReadDirectory { request_id, params } => { - self.handle_fs_read_directory( - ConnectionRequestId { - connection_id, - request_id, - }, - params, - ) - .await; + self.outgoing + .send_result( + request_id_for_connection(request_id), + self.fs_api.read_directory(params).await, + ) + .await; } ClientRequest::FsRemove { request_id, params } => { - self.handle_fs_remove( - ConnectionRequestId { - connection_id, - request_id, - }, - params, - ) - .await; + self.outgoing + .send_result( + request_id_for_connection(request_id), + self.fs_api.remove(params).await, + ) + .await; } ClientRequest::FsCopy { request_id, params } => { - self.handle_fs_copy( - ConnectionRequestId { - connection_id, - request_id, - }, - params, - ) - .await; + self.outgoing + .send_result( + request_id_for_connection(request_id), + self.fs_api.copy(params).await, + ) + .await; } ClientRequest::FsWatch { request_id, params } => { - self.handle_fs_watch( - ConnectionRequestId { - connection_id, - request_id, - }, - connection_id, - params, - ) - .await; + self.outgoing + .send_result( + request_id_for_connection(request_id), + self.fs_watch_manager.watch(connection_id, params).await, + ) + .await; } ClientRequest::FsUnwatch { request_id, params } => { - self.handle_fs_unwatch( - ConnectionRequestId { - connection_id, - request_id, - }, - connection_id, - params, - ) - .await; + self.outgoing + .send_result( + request_id_for_connection(request_id), + self.fs_watch_manager.unwatch(connection_id, params).await, + ) + .await; } other => { // Box the delegated future so this wrapper's async state machine does not @@ -1015,13 +913,7 @@ impl MessageProcessor { .await; } } - } - - async fn handle_config_read(&self, request_id: ConnectionRequestId, params: ConfigReadParams) { - match self.config_api.read(params).await { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, - } + Ok(()) } async fn handle_config_value_write( @@ -1078,7 +970,7 @@ impl MessageProcessor { let auth = self.auth_manager.auth().await; if !config.features.apps_enabled_for_auth( auth.as_ref() - .is_some_and(codex_login::CodexAuth::is_chatgpt_auth), + .is_some_and(codex_login::CodexAuth::uses_codex_backend), ) { return; } @@ -1166,271 +1058,136 @@ impl MessageProcessor { } } - async fn handle_config_requirements_read(&self, request_id: ConnectionRequestId) { - match self.config_api.config_requirements_read().await { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, - } - } - - async fn handle_device_key_create( + fn handle_device_key_create( &self, request_id: ConnectionRequestId, params: DeviceKeyCreateParams, device_key_requests_allowed: bool, ) { - if self - .reject_device_key_request_over_remote_transport( - request_id.clone(), - "device/key/create", - device_key_requests_allowed, - ) - .await - { - return; - } - - match self.device_key_api.create(params) { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, - } + self.spawn_device_key_request( + request_id, + "device/key/create", + device_key_requests_allowed, + move |device_key_api| async move { device_key_api.create(params).await }, + ); } - async fn handle_device_key_public( + fn handle_device_key_public( &self, request_id: ConnectionRequestId, params: DeviceKeyPublicParams, device_key_requests_allowed: bool, ) { - if self - .reject_device_key_request_over_remote_transport( - request_id.clone(), - "device/key/public", - device_key_requests_allowed, - ) - .await - { - return; - } - - match self.device_key_api.public(params) { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, - } + self.spawn_device_key_request( + request_id, + "device/key/public", + device_key_requests_allowed, + move |device_key_api| async move { device_key_api.public(params).await }, + ); } - async fn handle_device_key_sign( + fn handle_device_key_sign( &self, request_id: ConnectionRequestId, params: DeviceKeySignParams, device_key_requests_allowed: bool, ) { - if self - .reject_device_key_request_over_remote_transport( - request_id.clone(), - "device/key/sign", - device_key_requests_allowed, - ) - .await - { - return; - } - - match self.device_key_api.sign(params) { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, - } + self.spawn_device_key_request( + request_id, + "device/key/sign", + device_key_requests_allowed, + move |device_key_api| async move { device_key_api.sign(params).await }, + ); } - async fn reject_device_key_request_over_remote_transport( + fn spawn_device_key_request( &self, request_id: ConnectionRequestId, - method: &str, + method: &'static str, device_key_requests_allowed: bool, - ) -> bool { - if device_key_requests_allowed { - return false; - } - - self.outgoing - .send_error( - request_id, - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("{method} is not available over remote transports"), - data: None, - }, - ) + run_request: F, + ) where + R: serde::Serialize + Send + 'static, + F: FnOnce(DeviceKeyApi) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + { + let device_key_api = self.device_key_api.clone(); + let outgoing = Arc::clone(&self.outgoing); + tokio::spawn(async move { + let result = async { + if !device_key_requests_allowed { + return Err(invalid_request(format!( + "{method} is not available over remote transports" + ))); + } + run_request(device_key_api).await + } .await; - true - } - - async fn handle_external_agent_config_detect( - &self, - request_id: ConnectionRequestId, - params: ExternalAgentConfigDetectParams, - ) { - match self.external_agent_config_api.detect(params).await { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, - } + outgoing.send_result(request_id, result).await; + }); } async fn handle_external_agent_config_import( &self, request_id: ConnectionRequestId, params: ExternalAgentConfigImportParams, - ) { + ) -> Result<(), JSONRPCErrorError> { let has_plugin_imports = params.migration_items.iter().any(|item| { matches!( item.item_type, ExternalAgentConfigMigrationItemType::Plugins ) }); - match self.external_agent_config_api.import(params).await { - Ok(pending_plugin_imports) => { - if has_plugin_imports { - self.handle_config_mutation().await; - } - self.outgoing - .send_response(request_id, ExternalAgentConfigImportResponse {}) - .await; - if !has_plugin_imports { - return; - } + let pending_plugin_imports = self.external_agent_config_api.import(params).await?; + if has_plugin_imports { + self.handle_config_mutation().await; + } + self.outgoing + .send_response(request_id, ExternalAgentConfigImportResponse {}) + .await; - if pending_plugin_imports.is_empty() { - self.outgoing - .send_server_notification( - ServerNotification::ExternalAgentConfigImportCompleted( - ExternalAgentConfigImportCompletedNotification {}, - ), - ) - .await; - return; - } + if !has_plugin_imports { + return Ok(()); + } - let external_agent_config_api = self.external_agent_config_api.clone(); - let outgoing = Arc::clone(&self.outgoing); - let thread_manager = Arc::clone(&self.thread_manager); - tokio::spawn(async move { - for pending_plugin_import in pending_plugin_imports { - match external_agent_config_api - .complete_pending_plugin_import(pending_plugin_import) - .await - { - Ok(()) => {} - Err(error) => { - tracing::warn!( - error = %error.message, - "external agent config plugin import failed" - ); - } - } + if pending_plugin_imports.is_empty() { + self.outgoing + .send_server_notification(ServerNotification::ExternalAgentConfigImportCompleted( + ExternalAgentConfigImportCompletedNotification {}, + )) + .await; + return Ok(()); + } + + let external_agent_config_api = self.external_agent_config_api.clone(); + let outgoing = Arc::clone(&self.outgoing); + let thread_manager = Arc::clone(&self.thread_manager); + tokio::spawn(async move { + for pending_plugin_import in pending_plugin_imports { + match external_agent_config_api + .complete_pending_plugin_import(pending_plugin_import) + .await + { + Ok(()) => {} + Err(error) => { + tracing::warn!( + error = %error.message, + "external agent config plugin import failed" + ); } - thread_manager.plugins_manager().clear_cache(); - thread_manager.skills_manager().clear_cache(); - outgoing - .send_server_notification( - ServerNotification::ExternalAgentConfigImportCompleted( - ExternalAgentConfigImportCompletedNotification {}, - ), - ) - .await; - }); + } } - Err(error) => self.outgoing.send_error(request_id, error).await, - } - } + thread_manager.plugins_manager().clear_cache(); + thread_manager.skills_manager().clear_cache(); + outgoing + .send_server_notification(ServerNotification::ExternalAgentConfigImportCompleted( + ExternalAgentConfigImportCompletedNotification {}, + )) + .await; + }); - async fn handle_fs_read_file(&self, request_id: ConnectionRequestId, params: FsReadFileParams) { - match self.fs_api.read_file(params).await { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, - } - } - - async fn handle_fs_write_file( - &self, - request_id: ConnectionRequestId, - params: FsWriteFileParams, - ) { - match self.fs_api.write_file(params).await { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, - } - } - - async fn handle_fs_create_directory( - &self, - request_id: ConnectionRequestId, - params: FsCreateDirectoryParams, - ) { - match self.fs_api.create_directory(params).await { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, - } - } - - async fn handle_fs_get_metadata( - &self, - request_id: ConnectionRequestId, - params: FsGetMetadataParams, - ) { - match self.fs_api.get_metadata(params).await { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, - } - } - - async fn handle_fs_read_directory( - &self, - request_id: ConnectionRequestId, - params: FsReadDirectoryParams, - ) { - match self.fs_api.read_directory(params).await { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, - } - } - - async fn handle_fs_remove(&self, request_id: ConnectionRequestId, params: FsRemoveParams) { - match self.fs_api.remove(params).await { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, - } - } - - async fn handle_fs_copy(&self, request_id: ConnectionRequestId, params: FsCopyParams) { - match self.fs_api.copy(params).await { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, - } - } - - async fn handle_fs_watch( - &self, - request_id: ConnectionRequestId, - connection_id: ConnectionId, - params: FsWatchParams, - ) { - match self.fs_watch_manager.watch(connection_id, params).await { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, - } - } - - async fn handle_fs_unwatch( - &self, - request_id: ConnectionRequestId, - connection_id: ConnectionId, - params: FsUnwatchParams, - ) { - match self.fs_watch_manager.unwatch(connection_id, params).await { - Ok(response) => self.outgoing.send_response(request_id, response).await, - Err(error) => self.outgoing.send_error(request_id, error).await, - } + Ok(()) } } diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index e42dd5afbc..850d05c04e 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -27,10 +27,10 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput; use codex_arg0::Arg0DispatchPaths; +use codex_config::CloudRequirementsLoader; +use codex_config::LoaderOverrides; use codex_core::config::Config; use codex_core::config::ConfigBuilder; -use codex_core::config_loader::CloudRequirementsLoader; -use codex_core::config_loader::LoaderOverrides; use codex_exec_server::EnvironmentManager; use codex_feedback::CodexFeedback; use codex_login::AuthManager; @@ -48,6 +48,7 @@ use opentelemetry_sdk::trace::SpanData; use pretty_assertions::assert_eq; use serial_test::serial; use std::collections::BTreeMap; +use std::future::Future; use std::path::Path; use std::sync::Arc; use std::sync::OnceLock; @@ -126,7 +127,7 @@ impl TracingHarness { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; let config = Arc::new(build_test_config(codex_home.path(), &server.uri()).await?); - let (processor, outgoing_rx) = build_test_processor(config); + let (processor, outgoing_rx) = build_test_processor(config).await; let tracing = init_test_tracing(); tracing.exporter.reset(); tracing::callsite::rebuild_interest_cache(); @@ -187,7 +188,7 @@ impl TracingHarness { .process_request( TEST_CONNECTION_ID, request, - AppServerTransport::Stdio, + &AppServerTransport::Stdio, Arc::clone(&self.session), ) .await; @@ -210,7 +211,7 @@ impl TracingHarness { .process_request( TEST_CONNECTION_ID, request, - AppServerTransport::Stdio, + &AppServerTransport::Stdio, Arc::clone(&self.session), ) .await; @@ -256,7 +257,7 @@ async fn build_test_config(codex_home: &Path, server_uri: &str) -> Result, ) -> ( Arc, @@ -265,7 +266,7 @@ fn build_test_processor( let (outgoing_tx, outgoing_rx) = mpsc::channel(16); let outgoing = Arc::new(OutgoingMessageSender::new(outgoing_tx)); let auth_manager = - AuthManager::shared_from_config(config.as_ref(), /*enable_codex_api_key_env*/ false); + AuthManager::shared_from_config(config.as_ref(), /*enable_codex_api_key_env*/ false).await; let config_manager = ConfigManager::new( config.codex_home.to_path_buf(), Vec::new(), @@ -287,10 +288,33 @@ fn build_test_processor( auth_manager, rpc_transport: AppServerRpcTransport::Stdio, remote_control_handle: None, + plugin_startup_tasks: crate::PluginStartupTasks::Start, })); (processor, outgoing_rx) } +fn run_current_thread_test_with_stack(name: &str, future: F) -> Result<()> +where + F: Future> + Send + 'static, +{ + const TEST_STACK_SIZE_BYTES: usize = 4 * 1024 * 1024; + + let handle = std::thread::Builder::new() + .name(name.to_string()) + .stack_size(TEST_STACK_SIZE_BYTES) + .spawn(move || -> Result<()> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + runtime.block_on(Box::pin(future)) + })?; + + match handle.join() { + Ok(result) => result, + Err(_) => Err(anyhow::anyhow!("{name} thread panicked")), + } +} + fn span_attr<'a>(span: &'a SpanData, key: &str) -> Option<&'a str> { span.attributes .iter() @@ -568,83 +592,96 @@ where spans.into_iter().skip(baseline_len).collect() } -#[tokio::test(flavor = "current_thread")] +#[test] #[serial(app_server_tracing)] -async fn thread_start_jsonrpc_span_exports_server_span_and_parents_children() -> Result<()> { - let mut harness = TracingHarness::new().await?; +fn thread_start_jsonrpc_span_exports_server_span_and_parents_children() -> Result<()> { + run_current_thread_test_with_stack( + "thread_start_jsonrpc_span_exports_server_span_and_parents_children", + async { + let mut harness = TracingHarness::new().await?; - let RemoteTrace { - trace_id: remote_trace_id, - parent_span_id: remote_parent_span_id, - context: remote_trace, - .. - } = RemoteTrace::new("00000000000000000000000000000011", "0000000000000022"); + let RemoteTrace { + trace_id: remote_trace_id, + parent_span_id: remote_parent_span_id, + context: remote_trace, + .. + } = RemoteTrace::new("00000000000000000000000000000011", "0000000000000022"); - let _: ThreadStartResponse = harness - .start_thread(/*request_id*/ 20_002, /*trace*/ None) - .await; - let untraced_spans = wait_for_exported_spans(harness.tracing, |spans| { - spans.iter().any(|span| { - span.span_kind == SpanKind::Server - && span_attr(span, "rpc.method") == Some("thread/start") - }) - }) - .await; - let untraced_server_span = find_rpc_span_with_trace( - &untraced_spans, - SpanKind::Server, - "thread/start", - untraced_spans - .iter() - .rev() - .find(|span| { - span.span_kind == SpanKind::Server - && span_attr(span, "rpc.system") == Some("jsonrpc") - && span_attr(span, "rpc.method") == Some("thread/start") + let _: ThreadStartResponse = harness + .start_thread(/*request_id*/ 20_002, /*trace*/ None) + .await; + let untraced_spans = wait_for_exported_spans(harness.tracing, |spans| { + spans.iter().any(|span| { + span.span_kind == SpanKind::Server + && span_attr(span, "rpc.method") == Some("thread/start") + }) }) - .unwrap_or_else(|| { - panic!( - "missing latest thread/start server span; exported spans:\n{}", - format_spans(&untraced_spans) - ) + .await; + let untraced_server_span = find_rpc_span_with_trace( + &untraced_spans, + SpanKind::Server, + "thread/start", + untraced_spans + .iter() + .rev() + .find(|span| { + span.span_kind == SpanKind::Server + && span_attr(span, "rpc.system") == Some("jsonrpc") + && span_attr(span, "rpc.method") == Some("thread/start") + }) + .unwrap_or_else(|| { + panic!( + "missing latest thread/start server span; exported spans:\n{}", + format_spans(&untraced_spans) + ) + }) + .span_context + .trace_id(), + ); + assert_has_internal_descendant_at_min_depth( + &untraced_spans, + untraced_server_span, + /*min_depth*/ 1, + ); + + let baseline_len = untraced_spans.len(); + let _: ThreadStartResponse = harness + .start_thread(/*request_id*/ 20_003, Some(remote_trace)) + .await; + let spans = wait_for_new_exported_spans(harness.tracing, baseline_len, |spans| { + spans.iter().any(|span| { + span.span_kind == SpanKind::Server + && span_attr(span, "rpc.method") == Some("thread/start") + && span.span_context.trace_id() == remote_trace_id + }) && spans.iter().any(|span| { + span.name.as_ref() == "app_server.thread_start.notify_started" + && span.span_context.trace_id() == remote_trace_id + }) }) - .span_context - .trace_id(), - ); - assert_has_internal_descendant_at_min_depth( - &untraced_spans, - untraced_server_span, - /*min_depth*/ 1, - ); + .await; - let baseline_len = untraced_spans.len(); - let _: ThreadStartResponse = harness - .start_thread(/*request_id*/ 20_003, Some(remote_trace)) - .await; - let spans = wait_for_new_exported_spans(harness.tracing, baseline_len, |spans| { - spans.iter().any(|span| { - span.span_kind == SpanKind::Server - && span_attr(span, "rpc.method") == Some("thread/start") - && span.span_context.trace_id() == remote_trace_id - }) && spans.iter().any(|span| { - span.name.as_ref() == "app_server.thread_start.notify_started" - && span.span_context.trace_id() == remote_trace_id - }) - }) - .await; + let server_request_span = + find_rpc_span_with_trace(&spans, SpanKind::Server, "thread/start", remote_trace_id); + assert_eq!(server_request_span.name.as_ref(), "thread/start"); + assert_eq!(server_request_span.parent_span_id, remote_parent_span_id); + assert!(server_request_span.parent_span_is_remote); + assert_eq!(server_request_span.span_context.trace_id(), remote_trace_id); + assert_ne!(server_request_span.span_context.span_id(), SpanId::INVALID); + assert_has_internal_descendant_at_min_depth( + &spans, + server_request_span, + /*min_depth*/ 1, + ); + assert_has_internal_descendant_at_min_depth( + &spans, + server_request_span, + /*min_depth*/ 2, + ); + harness.shutdown().await; - let server_request_span = - find_rpc_span_with_trace(&spans, SpanKind::Server, "thread/start", remote_trace_id); - assert_eq!(server_request_span.name.as_ref(), "thread/start"); - assert_eq!(server_request_span.parent_span_id, remote_parent_span_id); - assert!(server_request_span.parent_span_is_remote); - assert_eq!(server_request_span.span_context.trace_id(), remote_trace_id); - assert_ne!(server_request_span.span_context.span_id(), SpanId::INVALID); - assert_has_internal_descendant_at_min_depth(&spans, server_request_span, /*min_depth*/ 1); - assert_has_internal_descendant_at_min_depth(&spans, server_request_span, /*min_depth*/ 2); - harness.shutdown().await; - - Ok(()) + Ok(()) + }, + ) } #[tokio::test(flavor = "current_thread")] @@ -707,6 +744,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { ClientRequest::TurnStart { request_id: RequestId::Integer(3), params: TurnStartParams { + environments: None, thread_id, input: vec![UserInput::Text { text: "hello".to_string(), @@ -716,6 +754,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { cwd: None, approval_policy: None, sandbox_policy: None, + permission_profile: None, approvals_reviewer: None, model: None, service_tier: None, diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 3c5bef3ea4..6ca1fcabaf 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -22,6 +22,7 @@ use tracing::Span; use tracing::warn; use crate::error_code::INTERNAL_ERROR_CODE; +use crate::error_code::internal_error; use crate::server_request_error::TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON; #[cfg(test)] @@ -196,7 +197,7 @@ impl ThreadScopedOutgoingMessageSender { pub(crate) async fn send_error( &self, request_id: ConnectionRequestId, - error: JSONRPCErrorError, + error: impl Into, ) { self.outgoing.send_error(request_id, error).await; } @@ -493,11 +494,7 @@ impl OutgoingMessageSender { self.send_error_inner( request_context, request_id, - JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to serialize response: {err}"), - data: None, - }, + internal_error(format!("failed to serialize response: {err}")), ) .await; } @@ -571,13 +568,27 @@ impl OutgoingMessageSender { pub(crate) async fn send_error( &self, request_id: ConnectionRequestId, - error: JSONRPCErrorError, + error: impl Into, ) { let request_context = self.take_request_context(&request_id).await; - self.send_error_inner(request_context, request_id, error) + self.send_error_inner(request_context, request_id, error.into()) .await; } + pub(crate) async fn send_result( + &self, + request_id: ConnectionRequestId, + result: std::result::Result, + ) where + T: Serialize, + E: Into, + { + match result { + Ok(response) => self.send_response(request_id, response).await, + Err(error) => self.send_error(request_id, error).await, + } + } + async fn send_error_inner( &self, request_context: Option, @@ -657,8 +668,11 @@ mod tests { use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::DynamicToolCallParams; use codex_app_server_protocol::FileChangeRequestApprovalParams; + use codex_app_server_protocol::GuardianWarningNotification; use codex_app_server_protocol::ModelRerouteReason; use codex_app_server_protocol::ModelReroutedNotification; + use codex_app_server_protocol::ModelVerification; + use codex_app_server_protocol::ModelVerificationNotification; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; use codex_app_server_protocol::ToolRequestUserInputParams; @@ -812,6 +826,28 @@ mod tests { ); } + #[test] + fn verify_guardian_warning_notification_serialization() { + let notification = ServerNotification::GuardianWarning(GuardianWarningNotification { + thread_id: "thread-1".to_string(), + message: "Automatic approval review denied the requested action.".to_string(), + }); + + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); + assert_eq!( + json!({ + "method": "guardianWarning", + "params": { + "threadId": "thread-1", + "message": "Automatic approval review denied the requested action.", + }, + }), + serde_json::to_value(jsonrpc_notification) + .expect("ensure the notification serializes correctly"), + "ensure the notification serializes correctly" + ); + } + #[test] fn verify_model_rerouted_notification_serialization() { let notification = ServerNotification::ModelRerouted(ModelReroutedNotification { @@ -840,6 +876,30 @@ mod tests { ); } + #[test] + fn verify_model_verification_notification_serialization() { + let notification = ServerNotification::ModelVerification(ModelVerificationNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + verifications: vec![ModelVerification::TrustedAccessForCyber], + }); + + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); + assert_eq!( + json!({ + "method": "model/verification", + "params": { + "threadId": "thread-1", + "turnId": "turn-1", + "verifications": ["trustedAccessForCyber"], + }, + }), + serde_json::to_value(jsonrpc_notification) + .expect("ensure the notification serializes correctly"), + "ensure the notification serializes correctly" + ); + } + #[tokio::test] async fn send_response_routes_to_target_connection() { let (tx, mut rx) = mpsc::channel::(4); diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 504e59468b..73d1c5961b 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -1,6 +1,7 @@ use crate::outgoing_message::ConnectionId; use crate::outgoing_message::ConnectionRequestId; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadGoal; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError; @@ -8,10 +9,11 @@ use codex_core::CodexThread; use codex_core::ThreadConfigSnapshot; use codex_protocol::ThreadId; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::RolloutItem; +use codex_rollout::state_db::StateDbHandle; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; use std::collections::HashSet; -use std::path::PathBuf; use std::sync::Arc; use std::sync::Weak; use tokio::sync::Mutex; @@ -27,16 +29,29 @@ type PendingInterruptQueue = Vec<( pub(crate) struct PendingThreadResumeRequest { pub(crate) request_id: ConnectionRequestId, - pub(crate) rollout_path: PathBuf, + pub(crate) history_items: Vec, pub(crate) config_snapshot: ThreadConfigSnapshot, pub(crate) instruction_sources: Vec, pub(crate) thread_summary: codex_app_server_protocol::Thread, + pub(crate) emit_thread_goal_update: bool, + pub(crate) thread_goal_state_db: Option, + pub(crate) include_turns: bool, } // ThreadListenerCommand is used to perform operations in the context of the thread listener, for serialization purposes. pub(crate) enum ThreadListenerCommand { // SendThreadResumeResponse is used to resume an already running thread by sending the thread's history to the client and atomically subscribing for new updates. SendThreadResumeResponse(Box), + // EmitThreadGoalUpdated is used to order app-server goal updates with running-thread resume responses. + EmitThreadGoalUpdated { + goal: ThreadGoal, + }, + // EmitThreadGoalCleared is used to order app-server goal clears with running-thread resume responses. + EmitThreadGoalCleared, + // EmitThreadGoalSnapshot is used to read and emit the latest goal state in the listener order. + EmitThreadGoalSnapshot { + state_db: StateDbHandle, + }, // ResolveServerRequest is used to notify the client that the request has been resolved. // It is executed in the thread listener's context to ensure that the resolved notification is ordered with regard to the request itself. ResolveServerRequest { @@ -59,6 +74,7 @@ pub(crate) struct ThreadState { pub(crate) pending_interrupts: PendingInterruptQueue, pub(crate) pending_rollbacks: Option, pub(crate) turn_summary: TurnSummary, + pub(crate) last_terminal_turn_id: Option, pub(crate) cancel_tx: Option>, pub(crate) experimental_raw_events: bool, pub(crate) listener_generation: u64, @@ -113,7 +129,7 @@ impl ThreadState { self.current_turn_history.active_turn_snapshot() } - pub(crate) fn track_current_turn_event(&mut self, event: &EventMsg) { + pub(crate) fn track_current_turn_event(&mut self, event_turn_id: &str, event: &EventMsg) { if let EventMsg::TurnStarted(payload) = event { self.turn_summary.started_at = payload.started_at; } @@ -121,6 +137,7 @@ impl ThreadState { if matches!(event, EventMsg::TurnAborted(_) | EventMsg::TurnComplete(_)) && !self.current_turn_history.has_active_turn() { + self.last_terminal_turn_id = Some(event_turn_id.to_string()); self.current_turn_history.reset(); } } diff --git a/codex-rs/app-server/src/transport/mod.rs b/codex-rs/app-server/src/transport/mod.rs index cb3510da77..b610f099ae 100644 --- a/codex-rs/app-server/src/transport/mod.rs +++ b/codex-rs/app-server/src/transport/mod.rs @@ -7,12 +7,16 @@ use crate::outgoing_message::OutgoingEnvelope; use crate::outgoing_message::OutgoingError; use crate::outgoing_message::OutgoingMessage; use crate::outgoing_message::QueuedOutgoingMessage; +use codex_app_server_protocol::ExperimentalApi; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::ServerRequest; +use codex_core::config::find_codex_home; +use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; use std::collections::HashSet; use std::net::SocketAddr; +use std::path::Path; use std::str::FromStr; use std::sync::Arc; use std::sync::RwLock; @@ -31,16 +35,32 @@ pub(crate) const CHANNEL_CAPACITY: usize = 128; mod remote_control; mod stdio; +mod unix_socket; +#[cfg(test)] +mod unix_socket_tests; mod websocket; pub(crate) use remote_control::RemoteControlHandle; pub(crate) use remote_control::start_remote_control; pub(crate) use stdio::start_stdio_connection; +pub(crate) use unix_socket::start_control_socket_acceptor; pub(crate) use websocket::start_websocket_acceptor; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +const APP_SERVER_CONTROL_SOCKET_DIR_NAME: &str = "app-server-control"; +const APP_SERVER_CONTROL_SOCKET_FILE_NAME: &str = "app-server-control.sock"; + +pub fn app_server_control_socket_path(codex_home: &Path) -> std::io::Result { + AbsolutePathBuf::from_absolute_path( + codex_home + .join(APP_SERVER_CONTROL_SOCKET_DIR_NAME) + .join(APP_SERVER_CONTROL_SOCKET_FILE_NAME), + ) +} + +#[derive(Clone, Debug, Eq, PartialEq)] pub enum AppServerTransport { Stdio, + UnixSocket { socket_path: AbsolutePathBuf }, WebSocket { bind_address: SocketAddr }, Off, } @@ -48,6 +68,7 @@ pub enum AppServerTransport { #[derive(Debug, Clone, Eq, PartialEq)] pub enum AppServerTransportParseError { UnsupportedListenUrl(String), + InvalidUnixSocketPath { listen_url: String, message: String }, InvalidWebSocketListenUrl(String), } @@ -56,7 +77,14 @@ impl std::fmt::Display for AppServerTransportParseError { match self { AppServerTransportParseError::UnsupportedListenUrl(listen_url) => write!( f, - "unsupported --listen URL `{listen_url}`; expected `stdio://`, `ws://IP:PORT`, or `off`" + "unsupported --listen URL `{listen_url}`; expected `stdio://`, `unix://`, `unix://PATH`, `ws://IP:PORT`, or `off`" + ), + AppServerTransportParseError::InvalidUnixSocketPath { + listen_url, + message, + } => write!( + f, + "invalid unix socket --listen URL `{listen_url}`; failed to resolve socket path: {message}" ), AppServerTransportParseError::InvalidWebSocketListenUrl(listen_url) => write!( f, @@ -76,6 +104,31 @@ impl AppServerTransport { return Ok(Self::Stdio); } + if let Some(raw_socket_path) = listen_url.strip_prefix("unix://") { + let socket_path = if raw_socket_path.is_empty() { + let codex_home = find_codex_home().map_err(|err| { + AppServerTransportParseError::InvalidUnixSocketPath { + listen_url: listen_url.to_string(), + message: format!("failed to resolve CODEX_HOME: {err}"), + } + })?; + app_server_control_socket_path(&codex_home).map_err(|err| { + AppServerTransportParseError::InvalidUnixSocketPath { + listen_url: listen_url.to_string(), + message: err.to_string(), + } + })? + } else { + AbsolutePathBuf::relative_to_current_dir(raw_socket_path).map_err(|err| { + AppServerTransportParseError::InvalidUnixSocketPath { + listen_url: listen_url.to_string(), + message: err.to_string(), + } + })? + }; + return Ok(Self::UnixSocket { socket_path }); + } + if listen_url == "off" { return Ok(Self::Off); } @@ -285,6 +338,13 @@ fn should_skip_notification_for_connection( }; match message { OutgoingMessage::AppServerNotification(notification) => { + if notification.experimental_reason().is_some() + && !connection_state + .experimental_api_enabled + .load(Ordering::Acquire) + { + return true; + } let method = notification.to_string(); opted_out_notification_methods.contains(method.as_str()) } @@ -417,6 +477,9 @@ mod tests { use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; + use codex_app_server_protocol::ThreadGoal; + use codex_app_server_protocol::ThreadGoalStatus; + use codex_app_server_protocol::ThreadGoalUpdatedNotification; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::json; @@ -427,6 +490,23 @@ mod tests { AbsolutePathBuf::from_absolute_path(path).expect("absolute path") } + fn thread_goal_updated_notification() -> ServerNotification { + ServerNotification::ThreadGoalUpdated(ThreadGoalUpdatedNotification { + thread_id: "thread-1".to_string(), + turn_id: None, + goal: ThreadGoal { + thread_id: "thread-1".to_string(), + objective: "ship goal mode".to_string(), + status: ThreadGoalStatus::Active, + token_budget: None, + tokens_used: 0, + time_used_seconds: 0, + created_at: 1, + updated_at: 1, + }, + }) + } + #[test] fn listen_off_parses_as_off_transport() { assert_eq!( @@ -758,6 +838,76 @@ mod tests { )); } + #[tokio::test] + async fn experimental_notifications_are_dropped_without_capability() { + let connection_id = ConnectionId(12); + let (writer_tx, mut writer_rx) = mpsc::channel(1); + + let mut connections = HashMap::new(); + connections.insert( + connection_id, + OutboundConnectionState::new( + writer_tx, + Arc::new(AtomicBool::new(true)), + Arc::new(AtomicBool::new(false)), + Arc::new(RwLock::new(HashSet::new())), + /*disconnect_sender*/ None, + ), + ); + + route_outgoing_envelope( + &mut connections, + OutgoingEnvelope::ToConnection { + connection_id, + message: OutgoingMessage::AppServerNotification(thread_goal_updated_notification()), + write_complete_tx: None, + }, + ) + .await; + + assert!( + writer_rx.try_recv().is_err(), + "experimental notifications should not reach clients without capability" + ); + } + + #[tokio::test] + async fn experimental_notifications_are_preserved_with_capability() { + let connection_id = ConnectionId(13); + let (writer_tx, mut writer_rx) = mpsc::channel(1); + + let mut connections = HashMap::new(); + connections.insert( + connection_id, + OutboundConnectionState::new( + writer_tx, + Arc::new(AtomicBool::new(true)), + Arc::new(AtomicBool::new(true)), + Arc::new(RwLock::new(HashSet::new())), + /*disconnect_sender*/ None, + ), + ); + + route_outgoing_envelope( + &mut connections, + OutgoingEnvelope::ToConnection { + connection_id, + message: OutgoingMessage::AppServerNotification(thread_goal_updated_notification()), + write_complete_tx: None, + }, + ) + .await; + + let message = writer_rx + .recv() + .await + .expect("experimental notification should reach opted-in client"); + assert!(matches!( + message.message, + OutgoingMessage::AppServerNotification(ServerNotification::ThreadGoalUpdated(_)) + )); + } + #[tokio::test] async fn command_execution_request_approval_strips_additional_permissions_without_capability() { let connection_id = ConnectionId(8); diff --git a/codex-rs/app-server/src/transport/remote_control/enroll.rs b/codex-rs/app-server/src/transport/remote_control/enroll.rs index dbe18c8355..ba69c459e8 100644 --- a/codex-rs/app-server/src/transport/remote_control/enroll.rs +++ b/codex-rs/app-server/src/transport/remote_control/enroll.rs @@ -2,6 +2,7 @@ use super::protocol::EnrollRemoteServerRequest; use super::protocol::EnrollRemoteServerResponse; use super::protocol::RemoteControlTarget; use axum::http::HeaderMap; +use codex_api::SharedAuthProvider; use codex_login::default_client::build_reqwest_client; use codex_state::RemoteControlEnrollmentRecord; use codex_state::StateRuntime; @@ -27,9 +28,8 @@ pub(super) struct RemoteControlEnrollment { pub(super) server_name: String, } -#[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct RemoteControlConnectionAuth { - pub(super) bearer_token: String, + pub(super) auth_provider: SharedAuthProvider, pub(super) account_id: String, } @@ -199,10 +199,12 @@ pub(super) async fn enroll_remote_control_server( app_server_version: env!("CARGO_PKG_VERSION"), }; let client = build_reqwest_client(); + let mut auth_headers = HeaderMap::new(); + auth.auth_provider.add_auth_headers(&mut auth_headers); let http_request = client .post(enroll_url) .timeout(REMOTE_CONTROL_ENROLL_TIMEOUT) - .bearer_auth(&auth.bearer_token) + .headers(auth_headers) .header(REMOTE_CONTROL_ACCOUNT_ID_HEADER, &auth.account_id) .json(&request); @@ -445,7 +447,7 @@ mod tests { let err = enroll_remote_control_server( &remote_control_target, &RemoteControlConnectionAuth { - bearer_token: "Access Token".to_string(), + auth_provider: codex_model_provider::unauthenticated_auth_provider(), account_id: "account_id".to_string(), }, ) diff --git a/codex-rs/app-server/src/transport/remote_control/tests.rs b/codex-rs/app-server/src/transport/remote_control/tests.rs index 99946b4916..82e02ae5de 100644 --- a/codex-rs/app-server/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server/src/transport/remote_control/tests.rs @@ -98,6 +98,7 @@ fn remote_control_auth_dot_json(account_id: Option<&str>) -> AuthDotJson { account_id: account_id.map(str::to_string), }), last_refresh: Some(chrono::Utc::now()), + agent_identity: None, } } @@ -495,7 +496,9 @@ async fn remote_control_start_allows_missing_auth_when_enabled() { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, - ); + /*chatgpt_base_url*/ None, + ) + .await; let (transport_event_tx, _transport_event_rx) = mpsc::channel::(CHANNEL_CAPACITY); let shutdown_token = CancellationToken::new(); @@ -1082,7 +1085,9 @@ async fn remote_control_waits_for_account_id_before_enrolling() { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, - ); + /*chatgpt_base_url*/ None, + ) + .await; let expected_server_name = gethostname().to_string_lossy().trim().to_string(); let expected_enrollment = RemoteControlEnrollment { account_id: "account_id".to_string(), diff --git a/codex-rs/app-server/src/transport/remote_control/websocket.rs b/codex-rs/app-server/src/transport/remote_control/websocket.rs index b68c98e733..4cd078455a 100644 --- a/codex-rs/app-server/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server/src/transport/remote_control/websocket.rs @@ -680,11 +680,9 @@ fn build_remote_control_websocket_request( "x-codex-protocol-version", REMOTE_CONTROL_PROTOCOL_VERSION, )?; - set_remote_control_header( - headers, - "authorization", - &format!("Bearer {}", auth.bearer_token), - )?; + let mut auth_headers = tungstenite::http::HeaderMap::new(); + auth.auth_provider.add_auth_headers(&mut auth_headers); + headers.extend(auth_headers); set_remote_control_header(headers, REMOTE_CONTROL_ACCOUNT_ID_HEADER, &auth.account_id)?; if let Some(subscribe_cursor) = subscribe_cursor { set_remote_control_header( @@ -708,22 +706,22 @@ pub(crate) async fn load_remote_control_auth( "remote control requires ChatGPT authentication", )); } - auth_manager.reload(); + auth_manager.reload().await; reloaded = true; continue; }; - if !auth.is_chatgpt_auth() { + if !auth.uses_codex_backend() { break auth; } if auth.get_account_id().is_none() && !reloaded { - auth_manager.reload(); + auth_manager.reload().await; reloaded = true; continue; } break auth; }; - if !auth.is_chatgpt_auth() { + if !auth.uses_codex_backend() { return Err(io::Error::new( ErrorKind::PermissionDenied, "remote control requires ChatGPT authentication; API key auth is not supported", @@ -731,7 +729,7 @@ pub(crate) async fn load_remote_control_auth( } Ok(RemoteControlConnectionAuth { - bearer_token: auth.get_token().map_err(io::Error::other)?, + auth_provider: codex_model_provider::auth_provider_from_auth(&auth), account_id: auth.get_account_id().ok_or_else(|| { io::Error::new( ErrorKind::WouldBlock, @@ -1011,6 +1009,7 @@ mod tests { account_id: Some("account_id".to_string()), }), last_refresh: Some(Utc::now()), + agent_identity: None, } } @@ -1090,7 +1089,9 @@ mod tests { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, - ); + /*chatgpt_base_url*/ None, + ) + .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut enrollment = Some(RemoteControlEnrollment { account_id: "account_id".to_string(), @@ -1171,7 +1172,9 @@ mod tests { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, - ); + /*chatgpt_base_url*/ None, + ) + .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut enrollment = None; save_auth( diff --git a/codex-rs/app-server/src/transport/unix_socket.rs b/codex-rs/app-server/src/transport/unix_socket.rs new file mode 100644 index 0000000000..5ab1377fb4 --- /dev/null +++ b/codex-rs/app-server/src/transport/unix_socket.rs @@ -0,0 +1,165 @@ +use std::io::ErrorKind; +use std::io::Result as IoResult; +use std::path::Path; + +use super::TransportEvent; +use crate::transport::websocket::run_websocket_connection; +use codex_uds::UnixListener; +use codex_uds::UnixStream; +use codex_utils_absolute_path::AbsolutePathBuf; +use futures::StreamExt; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; +use tokio::time::Duration; +use tokio_tungstenite::accept_async; +use tokio_util::sync::CancellationToken; +use tracing::error; +use tracing::info; +use tracing::warn; + +#[cfg(unix)] +const CONTROL_SOCKET_MODE: u32 = 0o600; + +pub(crate) async fn start_control_socket_acceptor( + socket_path: AbsolutePathBuf, + transport_event_tx: mpsc::Sender, + shutdown_token: CancellationToken, +) -> IoResult> { + prepare_control_socket_path(socket_path.as_path()).await?; + let listener = UnixListener::bind(socket_path.as_path()).await?; + let socket_guard = ControlSocketFileGuard { socket_path }; + set_control_socket_permissions(socket_guard.socket_path.as_path()).await?; + info!( + socket_path = %socket_guard.socket_path.display(), + "app-server control socket listening" + ); + + Ok(tokio::spawn(run_control_socket_acceptor( + listener, + transport_event_tx, + shutdown_token, + socket_guard, + ))) +} + +async fn run_control_socket_acceptor( + mut listener: UnixListener, + transport_event_tx: mpsc::Sender, + shutdown_token: CancellationToken, + socket_guard: ControlSocketFileGuard, +) { + let _socket_guard = socket_guard; + loop { + let stream = tokio::select! { + _ = shutdown_token.cancelled() => { + break; + } + result = listener.accept() => { + match result { + Ok(stream) => stream, + Err(err) => { + if matches!( + err.kind(), + ErrorKind::ConnectionAborted | ErrorKind::ConnectionReset | ErrorKind::Interrupted + ) { + warn!("recoverable control socket accept error: {err}"); + continue; + } + error!("control socket accept error: {err}"); + tokio::time::sleep(Duration::from_secs(1)).await; + continue; + } + } + } + }; + + let transport_event_tx = transport_event_tx.clone(); + tokio::spawn(async move { + let websocket_stream = match accept_async(stream).await { + Ok(websocket_stream) => websocket_stream, + Err(err) => { + warn!("failed to upgrade control socket websocket connection: {err}"); + return; + } + }; + let (websocket_writer, websocket_reader) = websocket_stream.split(); + run_websocket_connection(websocket_writer, websocket_reader, transport_event_tx).await; + }); + } + info!("control socket acceptor shutting down"); +} + +async fn prepare_control_socket_path(socket_path: &Path) -> IoResult<()> { + if let Some(parent) = socket_path.parent() { + codex_uds::prepare_private_socket_directory(parent).await?; + } + + match UnixStream::connect(socket_path).await { + Ok(_stream) => { + return Err(std::io::Error::new( + ErrorKind::AddrInUse, + format!( + "app-server control socket is already in use at {}", + socket_path.display() + ), + )); + } + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()), + Err(err) if err.kind() == ErrorKind::ConnectionRefused => {} + Err(err) => { + if !socket_path.exists() { + return Ok(()); + } + return Err(err); + } + } + + if !socket_path.try_exists()? { + return Ok(()); + } + + if !codex_uds::is_stale_socket_path(socket_path).await? { + return Err(std::io::Error::new( + ErrorKind::AlreadyExists, + format!( + "app-server control socket path exists and is not a socket: {}", + socket_path.display() + ), + )); + } + tokio::fs::remove_file(socket_path).await +} + +#[cfg(unix)] +async fn set_control_socket_permissions(socket_path: &Path) -> IoResult<()> { + use std::os::unix::fs::PermissionsExt; + + tokio::fs::set_permissions( + socket_path, + std::fs::Permissions::from_mode(CONTROL_SOCKET_MODE), + ) + .await +} + +#[cfg(not(unix))] +async fn set_control_socket_permissions(_socket_path: &Path) -> IoResult<()> { + Ok(()) +} + +struct ControlSocketFileGuard { + socket_path: AbsolutePathBuf, +} + +impl Drop for ControlSocketFileGuard { + fn drop(&mut self) { + if let Err(err) = std::fs::remove_file(self.socket_path.as_path()) + && err.kind() != ErrorKind::NotFound + { + warn!( + socket_path = %self.socket_path.display(), + %err, + "failed to remove app-server control socket file" + ); + } + } +} diff --git a/codex-rs/app-server/src/transport/unix_socket_tests.rs b/codex-rs/app-server/src/transport/unix_socket_tests.rs new file mode 100644 index 0000000000..0b7dec0a23 --- /dev/null +++ b/codex-rs/app-server/src/transport/unix_socket_tests.rs @@ -0,0 +1,201 @@ +use super::AppServerTransport; +use super::CHANNEL_CAPACITY; +use super::TransportEvent; +use super::app_server_control_socket_path; +use super::start_control_socket_acceptor; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCNotification; +use codex_core::config::find_codex_home; +use codex_uds::UnixStream; +use codex_utils_absolute_path::AbsolutePathBuf; +use futures::SinkExt; +use futures::StreamExt; +use pretty_assertions::assert_eq; +use std::io::Result as IoResult; +use std::path::Path; +use tokio::sync::mpsc; +use tokio::time::Duration; +use tokio::time::timeout; +use tokio_tungstenite::client_async; +use tokio_tungstenite::tungstenite::Bytes; +use tokio_tungstenite::tungstenite::Message as WebSocketMessage; +use tokio_util::sync::CancellationToken; + +#[test] +fn listen_unix_socket_parses_as_unix_socket_transport() { + assert_eq!( + AppServerTransport::from_listen_url("unix://"), + Ok(AppServerTransport::UnixSocket { + socket_path: default_control_socket_path() + }) + ); +} + +#[test] +fn listen_unix_socket_accepts_absolute_custom_path() { + assert_eq!( + AppServerTransport::from_listen_url("unix:///tmp/codex.sock"), + Ok(AppServerTransport::UnixSocket { + socket_path: absolute_path("/tmp/codex.sock") + }) + ); +} + +#[test] +fn listen_unix_socket_accepts_relative_custom_path() { + assert_eq!( + AppServerTransport::from_listen_url("unix://codex.sock"), + Ok(AppServerTransport::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock") + .expect("relative path should resolve") + }) + ); +} + +#[tokio::test] +async fn control_socket_acceptor_upgrades_and_forwards_websocket_text_messages_and_pings() { + let temp_dir = tempfile::TempDir::new().expect("temp dir"); + let socket_path = test_socket_path(temp_dir.path()); + let (transport_event_tx, mut transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let accept_handle = start_control_socket_acceptor( + socket_path.clone(), + transport_event_tx, + shutdown_token.clone(), + ) + .await + .expect("control socket acceptor should start"); + + let stream = connect_to_socket(socket_path.as_path()) + .await + .expect("client should connect"); + let (mut websocket, response) = client_async("ws://localhost/rpc", stream) + .await + .expect("websocket upgrade should complete"); + assert_eq!(response.status().as_u16(), 101); + + let opened = timeout(Duration::from_secs(1), transport_event_rx.recv()) + .await + .expect("connection opened event should arrive") + .expect("connection opened event"); + let connection_id = match opened { + TransportEvent::ConnectionOpened { connection_id, .. } => connection_id, + _ => panic!("expected connection opened event"), + }; + + let notification = JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); + websocket + .send(WebSocketMessage::Text( + serde_json::to_string(¬ification) + .expect("notification should serialize") + .into(), + )) + .await + .expect("notification should send"); + + let incoming = timeout(Duration::from_secs(1), transport_event_rx.recv()) + .await + .expect("incoming message event should arrive") + .expect("incoming message event"); + assert_eq!( + match incoming { + TransportEvent::IncomingMessage { + connection_id: incoming_connection_id, + message, + } => (incoming_connection_id, message), + _ => panic!("expected incoming message event"), + }, + (connection_id, notification) + ); + + websocket + .send(WebSocketMessage::Ping(Bytes::from_static(b"check"))) + .await + .expect("ping should send"); + let pong = timeout(Duration::from_secs(1), websocket.next()) + .await + .expect("pong should arrive") + .expect("pong frame") + .expect("pong should be valid"); + assert_eq!(pong, WebSocketMessage::Pong(Bytes::from_static(b"check"))); + + websocket.close(None).await.expect("close should send"); + let closed = timeout(Duration::from_secs(1), transport_event_rx.recv()) + .await + .expect("connection closed event should arrive") + .expect("connection closed event"); + assert!(matches!( + closed, + TransportEvent::ConnectionClosed { + connection_id: closed_connection_id, + } if closed_connection_id == connection_id + )); + + shutdown_token.cancel(); + accept_handle.await.expect("acceptor should join"); + assert_socket_path_removed(socket_path.as_path()); +} + +#[cfg(unix)] +#[tokio::test] +async fn control_socket_file_is_private_after_bind() { + use std::os::unix::fs::PermissionsExt; + + let temp_dir = tempfile::TempDir::new().expect("temp dir"); + let socket_path = test_socket_path(temp_dir.path()); + let (transport_event_tx, _transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let accept_handle = start_control_socket_acceptor( + socket_path.clone(), + transport_event_tx, + shutdown_token.clone(), + ) + .await + .expect("control socket acceptor should start"); + + let metadata = tokio::fs::metadata(socket_path.as_path()) + .await + .expect("socket metadata should exist"); + assert_eq!(metadata.permissions().mode() & 0o777, 0o600); + + shutdown_token.cancel(); + accept_handle.await.expect("acceptor should join"); +} + +fn absolute_path(path: &str) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path(path).expect("absolute path") +} + +fn default_control_socket_path() -> AbsolutePathBuf { + let codex_home = find_codex_home().expect("codex home"); + app_server_control_socket_path(&codex_home).expect("default control socket path") +} + +fn test_socket_path(temp_dir: &Path) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path( + temp_dir + .join("app-server-control") + .join("app-server-control.sock"), + ) + .expect("socket path should resolve") +} + +async fn connect_to_socket(socket_path: &Path) -> IoResult { + UnixStream::connect(socket_path).await +} + +#[cfg(unix)] +fn assert_socket_path_removed(socket_path: &Path) { + assert!(!socket_path.exists()); +} + +#[cfg(windows)] +fn assert_socket_path_removed(_socket_path: &Path) { + // uds_windows uses a regular filesystem path as its rendezvous point, + // but there is no Unix socket filesystem node to assert on. +} diff --git a/codex-rs/app-server/src/transport/websocket.rs b/codex-rs/app-server/src/transport/websocket.rs index 2647d7c768..7830189467 100644 --- a/codex-rs/app-server/src/transport/websocket.rs +++ b/codex-rs/app-server/src/transport/websocket.rs @@ -11,10 +11,10 @@ use crate::outgoing_message::ConnectionId; use crate::outgoing_message::QueuedOutgoingMessage; use axum::Router; use axum::body::Body; +use axum::body::Bytes; use axum::extract::ConnectInfo; use axum::extract::State; -use axum::extract::ws::Message as WebSocketMessage; -use axum::extract::ws::WebSocket; +use axum::extract::ws::Message as AxumWebSocketMessage; use axum::extract::ws::WebSocketUpgrade; use axum::http::HeaderMap; use axum::http::Request; @@ -37,11 +37,17 @@ use std::sync::Arc; use tokio::net::TcpListener; use tokio::sync::mpsc; use tokio::task::JoinHandle; +use tokio_tungstenite::tungstenite::Message as TungsteniteWebSocketMessage; use tokio_util::sync::CancellationToken; use tracing::error; use tracing::info; use tracing::warn; +/// WebSocket clients can briefly lag behind normal turn output bursts while the +/// writer task is healthy, so give them more headroom than internal channels. +const WEBSOCKET_OUTBOUND_CHANNEL_CAPACITY: usize = 32 * 1024; +const _: () = assert!(WEBSOCKET_OUTBOUND_CHANNEL_CAPACITY > CHANNEL_CAPACITY); + fn colorize(text: &str, style: Style) -> String { text.if_supports_color(Stream::Stderr, |value| value.style(style)) .to_string() @@ -112,11 +118,12 @@ async fn websocket_upgrade_handler( ); return (err.status_code(), err.message()).into_response(); } - let connection_id = next_connection_id(); info!(%peer_addr, "websocket client connected"); websocket .on_upgrade(move |stream| async move { - run_websocket_connection(connection_id, stream, state.transport_event_tx).await; + let (websocket_writer, websocket_reader) = stream.split(); + run_websocket_connection(websocket_writer, websocket_reader, state.transport_event_tx) + .await; }) .into_response() } @@ -162,12 +169,18 @@ pub(crate) async fn start_websocket_acceptor( })) } -async fn run_websocket_connection( - connection_id: ConnectionId, - websocket_stream: WebSocket, +pub(crate) async fn run_websocket_connection( + websocket_writer: impl futures::sink::Sink + Send + 'static, + websocket_reader: impl futures::stream::Stream> + Send + 'static, transport_event_tx: mpsc::Sender, -) { - let (writer_tx, writer_rx) = mpsc::channel::(CHANNEL_CAPACITY); +) where + M: AppServerWebSocketMessage + Send + 'static, + SinkError: Send + 'static, + StreamError: std::fmt::Display + Send + 'static, +{ + let connection_id = next_connection_id(); + let (writer_tx, writer_rx) = + mpsc::channel::(WEBSOCKET_OUTBOUND_CHANNEL_CAPACITY); let writer_tx_for_reader = writer_tx.clone(); let disconnect_token = CancellationToken::new(); if transport_event_tx @@ -183,9 +196,7 @@ async fn run_websocket_connection( return; } - let (websocket_writer, websocket_reader) = websocket_stream.split(); - let (writer_control_tx, writer_control_rx) = - mpsc::channel::(CHANNEL_CAPACITY); + let (writer_control_tx, writer_control_rx) = mpsc::channel::(CHANNEL_CAPACITY); let mut outbound_task = tokio::spawn(run_websocket_outbound_loop( websocket_writer, writer_rx, @@ -217,12 +228,74 @@ async fn run_websocket_connection( .await; } -async fn run_websocket_outbound_loop( - mut websocket_writer: futures::stream::SplitSink, +pub(crate) enum IncomingWebSocketMessage { + Text(String), + Binary, + Ping(Bytes), + Pong, + Close, +} + +/// Converts concrete WebSocket message types into the small message surface the +/// app-server transport needs, and constructs the only outbound frames it +/// sends directly. +pub(crate) trait AppServerWebSocketMessage: Sized { + fn text(text: String) -> Self; + fn pong(payload: Bytes) -> Self; + fn into_incoming(self) -> Option; +} + +impl AppServerWebSocketMessage for AxumWebSocketMessage { + fn text(text: String) -> Self { + Self::Text(text.into()) + } + + fn pong(payload: Bytes) -> Self { + Self::Pong(payload) + } + + fn into_incoming(self) -> Option { + Some(match self { + Self::Text(text) => IncomingWebSocketMessage::Text(text.to_string()), + Self::Binary(_) => IncomingWebSocketMessage::Binary, + Self::Ping(payload) => IncomingWebSocketMessage::Ping(payload), + Self::Pong(_) => IncomingWebSocketMessage::Pong, + Self::Close(_) => IncomingWebSocketMessage::Close, + }) + } +} + +impl AppServerWebSocketMessage for TungsteniteWebSocketMessage { + fn text(text: String) -> Self { + Self::Text(text.into()) + } + + fn pong(payload: Bytes) -> Self { + Self::Pong(payload) + } + + fn into_incoming(self) -> Option { + Some(match self { + Self::Text(text) => IncomingWebSocketMessage::Text(text.to_string()), + Self::Binary(_) => IncomingWebSocketMessage::Binary, + Self::Ping(payload) => IncomingWebSocketMessage::Ping(payload), + Self::Pong(_) => IncomingWebSocketMessage::Pong, + Self::Close(_) => IncomingWebSocketMessage::Close, + Self::Frame(_) => return None, + }) + } +} + +async fn run_websocket_outbound_loop( + websocket_writer: impl futures::sink::Sink + Send + 'static, mut writer_rx: mpsc::Receiver, - mut writer_control_rx: mpsc::Receiver, + mut writer_control_rx: mpsc::Receiver, disconnect_token: CancellationToken, -) { +) where + M: AppServerWebSocketMessage + Send + 'static, + SinkError: Send + 'static, +{ + tokio::pin!(websocket_writer); loop { tokio::select! { _ = disconnect_token.cancelled() => { @@ -243,7 +316,7 @@ async fn run_websocket_outbound_loop( let Some(json) = serialize_outgoing_message(queued_message.message) else { continue; }; - if websocket_writer.send(WebSocketMessage::Text(json.into())).await.is_err() { + if websocket_writer.send(M::text(json)).await.is_err() { break; } if let Some(write_complete_tx) = queued_message.write_complete_tx { @@ -254,14 +327,18 @@ async fn run_websocket_outbound_loop( } } -async fn run_websocket_inbound_loop( - mut websocket_reader: futures::stream::SplitStream, +async fn run_websocket_inbound_loop( + websocket_reader: impl futures::stream::Stream> + Send + 'static, transport_event_tx: mpsc::Sender, writer_tx_for_reader: mpsc::Sender, - writer_control_tx: mpsc::Sender, + writer_control_tx: mpsc::Sender, connection_id: ConnectionId, disconnect_token: CancellationToken, -) { +) where + M: AppServerWebSocketMessage + Send + 'static, + StreamError: std::fmt::Display + Send + 'static, +{ + tokio::pin!(websocket_reader); loop { tokio::select! { _ = disconnect_token.cancelled() => { @@ -269,33 +346,37 @@ async fn run_websocket_inbound_loop( } incoming_message = websocket_reader.next() => { match incoming_message { - Some(Ok(WebSocketMessage::Text(text))) => { - if !forward_incoming_message( - &transport_event_tx, - &writer_tx_for_reader, - connection_id, - text.as_ref(), - ) - .await - { - break; - } - } - Some(Ok(WebSocketMessage::Ping(payload))) => { - match writer_control_tx.try_send(WebSocketMessage::Pong(payload)) { - Ok(()) => {} - Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => break, - Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { - warn!("websocket control queue full while replying to ping; closing connection"); + Some(Ok(message)) => match message.into_incoming() { + Some(IncomingWebSocketMessage::Text(text)) => { + if !forward_incoming_message( + &transport_event_tx, + &writer_tx_for_reader, + connection_id, + &text, + ) + .await + { break; } } - } - Some(Ok(WebSocketMessage::Pong(_))) => {} - Some(Ok(WebSocketMessage::Close(_))) | None => break, - Some(Ok(WebSocketMessage::Binary(_))) => { - warn!("dropping unsupported binary websocket message"); - } + Some(IncomingWebSocketMessage::Ping(payload)) => { + match writer_control_tx.try_send(M::pong(payload)) { + Ok(()) => {} + Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => break, + Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { + warn!("websocket control queue full while replying to ping; closing connection"); + break; + } + } + } + Some(IncomingWebSocketMessage::Pong) => {} + Some(IncomingWebSocketMessage::Close) => break, + Some(IncomingWebSocketMessage::Binary) => { + warn!("dropping unsupported binary websocket message"); + } + None => {} + }, + None => break, Some(Err(err)) => { warn!("websocket receive error: {err}"); break; diff --git a/codex-rs/app-server/tests/common/auth_fixtures.rs b/codex-rs/app-server/tests/common/auth_fixtures.rs index 99334f0770..86f0fb456d 100644 --- a/codex-rs/app-server/tests/common/auth_fixtures.rs +++ b/codex-rs/app-server/tests/common/auth_fixtures.rs @@ -163,6 +163,7 @@ pub fn write_chatgpt_auth( openai_api_key: None, tokens: Some(tokens), last_refresh, + agent_identity: None, }; save_auth(codex_home, &auth, cli_auth_credentials_store_mode).context("write auth.json") diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 6ac26d8a56..6bb600bd82 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -25,6 +25,7 @@ pub use core_test_support::test_path_buf_with_windows; pub use core_test_support::test_tmp_path; pub use core_test_support::test_tmp_path_buf; pub use mcp_process::DEFAULT_CLIENT_NAME; +pub use mcp_process::DISABLE_PLUGIN_STARTUP_TASKS_ARG; pub use mcp_process::McpProcess; pub use mock_model_server::create_mock_responses_server_repeating_assistant; pub use mock_model_server::create_mock_responses_server_sequence; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 2279939ecd..bcd364c742 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -49,6 +49,7 @@ use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::LoginAccountParams; use codex_app_server_protocol::MarketplaceAddParams; use codex_app_server_protocol::MarketplaceRemoveParams; +use codex_app_server_protocol::MarketplaceUpgradeParams; use codex_app_server_protocol::McpResourceReadParams; use codex_app_server_protocol::McpServerToolCallParams; use codex_app_server_protocol::MockExperimentalMethodParams; @@ -105,19 +106,26 @@ pub struct McpProcess { } pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests"; +pub const DISABLE_PLUGIN_STARTUP_TASKS_ARG: &str = "--disable-plugin-startup-tasks-for-tests"; const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; impl McpProcess { pub async fn new(codex_home: &Path) -> anyhow::Result { - Self::new_with_env_and_args(codex_home, &[], &[]).await + Self::new_with_env_and_args(codex_home, &[], &[DISABLE_PLUGIN_STARTUP_TASKS_ARG]).await } pub async fn new_without_managed_config(codex_home: &Path) -> anyhow::Result { Self::new_with_env(codex_home, &[(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))]).await } + pub async fn new_with_plugin_startup_tasks(codex_home: &Path) -> anyhow::Result { + Self::new_with_env_and_args(codex_home, &[], &[]).await + } + pub async fn new_with_args(codex_home: &Path, args: &[&str]) -> anyhow::Result { - Self::new_with_env_and_args(codex_home, &[], args).await + let mut all_args = vec![DISABLE_PLUGIN_STARTUP_TASKS_ARG]; + all_args.extend_from_slice(args); + Self::new_with_env_and_args(codex_home, &[], &all_args).await } /// Creates a new MCP process, allowing tests to override or remove @@ -129,7 +137,12 @@ impl McpProcess { codex_home: &Path, env_overrides: &[(&str, Option<&str>)], ) -> anyhow::Result { - Self::new_with_env_and_args(codex_home, env_overrides, &[]).await + Self::new_with_env_and_args( + codex_home, + env_overrides, + &[DISABLE_PLUGIN_STARTUP_TASKS_ARG], + ) + .await } async fn new_with_env_and_args( @@ -146,7 +159,7 @@ impl McpProcess { cmd.stderr(Stdio::piped()); cmd.current_dir(codex_home); cmd.env("CODEX_HOME", codex_home); - cmd.env("RUST_LOG", "info"); + cmd.env("RUST_LOG", "warn"); // Keep integration tests isolated from host managed configuration. cmd.env( "CODEX_APP_SERVER_MANAGED_CONFIG_PATH", @@ -565,6 +578,15 @@ impl McpProcess { self.send_request("marketplace/remove", params).await } + /// Send a `marketplace/upgrade` JSON-RPC request. + pub async fn send_marketplace_upgrade_request( + &mut self, + params: MarketplaceUpgradeParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("marketplace/upgrade", params).await + } + /// Send a `plugin/install` JSON-RPC request. pub async fn send_plugin_install_request( &mut self, diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 557fa56204..3b4a58a7ab 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -36,7 +36,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { default_reasoning_summary: ReasoningSummary::Auto, support_verbosity: false, default_verbosity: None, - availability_nux: None, + availability_nux: preset.availability_nux.clone(), apply_patch_tool_type: None, web_search_tool_type: Default::default(), truncation_policy: TruncationPolicyConfig::bytes(/*limit*/ 10_000), diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index b05cee8230..bb938d9ae7 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -11,7 +11,9 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_protocol::ThreadId; use codex_protocol::protocol::SessionSource; +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; +use std::path::Path; use std::path::PathBuf; use tempfile::TempDir; use tokio::time::timeout; @@ -40,6 +42,15 @@ fn expected_summary(conversation_id: ThreadId, path: PathBuf) -> ConversationSum } } +fn normalized_canonical_path(path: impl AsRef) -> Result { + Ok(AbsolutePathBuf::from_absolute_path(path.as_ref().canonicalize()?)?.into_path_buf()) +} + +fn normalized_summary_path(mut summary: ConversationSummary) -> Result { + summary.path = normalized_canonical_path(&summary.path)?; + Ok(summary) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_conversation_summary_by_thread_id_reads_rollout() -> Result<()> { let codex_home = TempDir::new()?; @@ -54,7 +65,7 @@ async fn get_conversation_summary_by_thread_id_reads_rollout() -> Result<()> { let thread_id = ThreadId::from_string(&conversation_id)?; let expected = expected_summary( thread_id, - std::fs::canonicalize(rollout_path( + normalized_canonical_path(rollout_path( codex_home.path(), FILENAME_TS, &conversation_id, @@ -76,7 +87,7 @@ async fn get_conversation_summary_by_thread_id_reads_rollout() -> Result<()> { .await??; let received: GetConversationSummaryResponse = to_response(response)?; - assert_eq!(received.summary, expected); + assert_eq!(normalized_summary_path(received.summary)?, expected); Ok(()) } @@ -126,7 +137,7 @@ async fn get_conversation_summary_by_relative_rollout_path_resolves_from_codex_h let thread_id = ThreadId::from_string(&conversation_id)?; let rollout_path = rollout_path(codex_home.path(), FILENAME_TS, &conversation_id); let relative_path = rollout_path.strip_prefix(codex_home.path())?.to_path_buf(); - let expected = expected_summary(thread_id, std::fs::canonicalize(rollout_path)?); + let expected = expected_summary(thread_id, normalized_canonical_path(rollout_path)?); let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -143,6 +154,6 @@ async fn get_conversation_summary_by_relative_rollout_path_resolves_from_codex_h .await??; let received: GetConversationSummaryResponse = to_response(response)?; - assert_eq!(received.summary, expected); + assert_eq!(normalized_summary_path(received.summary)?, expected); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 3c88bcb7a4..2d75fd10a2 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -55,6 +55,8 @@ struct CreateConfigTomlParams { forced_workspace_id: Option, requires_openai_auth: Option, base_url: Option, + model_provider_id: Option, + extra_provider_config: Option, } fn create_config_toml(codex_home: &Path, params: CreateConfigTomlParams) -> std::io::Result<()> { @@ -77,6 +79,23 @@ fn create_config_toml(codex_home: &Path, params: CreateConfigTomlParams) -> std: Some(false) => String::new(), None => String::new(), }; + let model_provider_id = params + .model_provider_id + .unwrap_or_else(|| "mock_provider".to_string()); + let provider_section = if model_provider_id == "mock_provider" { + format!( + r#"[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{base_url}" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +{requires_line} +"# + ) + } else { + params.extra_provider_config.unwrap_or_default() + }; let contents = format!( r#" model = "mock-model" @@ -85,18 +104,12 @@ sandbox_mode = "danger-full-access" {forced_line} {forced_workspace_line} -model_provider = "mock_provider" +model_provider = "{model_provider_id}" [features] shell_snapshot = false -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{base_url}" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -{requires_line} +{provider_section} "# ); std::fs::write(config_toml, contents) @@ -1545,6 +1558,47 @@ async fn get_account_when_auth_not_required() -> Result<()> { Ok(()) } +#[tokio::test] +async fn get_account_with_aws_provider() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + model_provider_id: Some("amazon-bedrock".to_string()), + extra_provider_config: Some( + r#"[model_providers.amazon-bedrock.aws] +profile = "codex-bedrock" +region = "us-west-2" +"# + .to_string(), + ), + ..Default::default() + }, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let params = GetAccountParams { + refresh_token: false, + }; + let request_id = mcp.send_get_account_request(params).await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetAccountResponse = to_response(resp)?; + + let expected = GetAccountResponse { + account: Some(Account::AmazonBedrock {}), + requires_openai_auth: false, + }; + assert_eq!(received, expected); + Ok(()) +} + #[tokio::test] async fn get_account_with_chatgpt() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a3ecdbc1f4..862721a154 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -79,24 +79,6 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { Ok(()) } -pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { - let config_path = codex_home.join("config.toml"); - let config_toml = std::fs::read_to_string(&config_path)?; - if !config_toml.contains("[features]") { - std::fs::write( - &config_path, - format!("{config_toml}\n[features]\ngeneral_analytics = true\n"), - )?; - } else if !config_toml.contains("general_analytics") { - std::fs::write( - &config_path, - config_toml.replace("[features]\n", "[features]\ngeneral_analytics = true\n"), - )?; - } - - mount_analytics_capture(server, codex_home).await -} - pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { Mock::given(method("POST")) .and(path("/codex/analytics-events/events")) diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 7aa8034528..335489929d 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -119,6 +119,7 @@ async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> { openai_api_key: Some("test-api-key".to_string()), tokens: None, last_refresh: None, + agent_identity: None, }, AuthCredentialsStoreMode::File, )?; @@ -150,6 +151,68 @@ async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> { Ok(()) } +#[tokio::test] +async fn list_apps_returns_empty_when_workspace_codex_plugins_disabled() -> Result<()> { + let connectors = vec![AppInfo { + id: "beta".to_string(), + name: "Beta".to_string(), + description: Some("Beta connector".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + let tools = vec![connector_tool("beta", "Beta App")?]; + let (server_url, server_handle) = start_apps_server_with_workspace_plugins_enabled( + connectors, tools, /*workspace_plugins_enabled*/ false, + ) + .await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .plan_type("team"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_apps_list_request(AppsListParams { + limit: Some(50), + cursor: None, + thread_id: None, + force_refetch: false, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let AppsListResponse { data, next_cursor } = to_response(response)?; + assert!(data.is_empty()); + assert!(next_cursor.is_none()); + + server_handle.abort(); + let _ = server_handle.await; + Ok(()) +} + #[tokio::test] async fn list_apps_uses_thread_feature_flag_when_thread_id_is_provided() -> Result<()> { let connectors = vec![AppInfo { @@ -1328,6 +1391,7 @@ struct AppsServerState { expected_account_id: String, response: Arc>, directory_delay: Duration, + workspace_plugins_enabled: bool, } #[derive(Clone)] @@ -1411,11 +1475,45 @@ async fn start_apps_server_with_delays( Ok((server_url, server_handle)) } +async fn start_apps_server_with_workspace_plugins_enabled( + connectors: Vec, + tools: Vec, + workspace_plugins_enabled: bool, +) -> Result<(String, JoinHandle<()>)> { + let (server_url, server_handle, _server_control) = + start_apps_server_with_delays_and_control_inner( + connectors, + tools, + Duration::ZERO, + Duration::ZERO, + workspace_plugins_enabled, + ) + .await?; + Ok((server_url, server_handle)) +} + async fn start_apps_server_with_delays_and_control( connectors: Vec, tools: Vec, directory_delay: Duration, tools_delay: Duration, +) -> Result<(String, JoinHandle<()>, AppsServerControl)> { + start_apps_server_with_delays_and_control_inner( + connectors, + tools, + directory_delay, + tools_delay, + /*workspace_plugins_enabled*/ true, + ) + .await +} + +async fn start_apps_server_with_delays_and_control_inner( + connectors: Vec, + tools: Vec, + directory_delay: Duration, + tools_delay: Duration, + workspace_plugins_enabled: bool, ) -> Result<(String, JoinHandle<()>, AppsServerControl)> { let response = Arc::new(StdMutex::new( json!({ "apps": connectors, "next_token": null }), @@ -1426,6 +1524,7 @@ async fn start_apps_server_with_delays_and_control( expected_account_id: "account-123".to_string(), response: response.clone(), directory_delay, + workspace_plugins_enabled, }; let state = Arc::new(state); let server_control = AppsServerControl { @@ -1451,6 +1550,10 @@ async fn start_apps_server_with_delays_and_control( "/connectors/directory/list_workspace", get(list_directory_connectors), ) + .route( + "/accounts/account-123/settings", + get(workspace_settings_response), + ) .with_state(state) .nest_service("/api/codex/apps", mcp_service); @@ -1461,6 +1564,30 @@ async fn start_apps_server_with_delays_and_control( Ok((format!("http://{addr}"), handle, server_control)) } +async fn workspace_settings_response( + State(state): State>, + headers: HeaderMap, +) -> Result { + let bearer_ok = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == state.expected_bearer); + let account_ok = headers + .get("chatgpt-account-id") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == state.expected_account_id); + + if !bearer_ok || !account_ok { + Err(StatusCode::UNAUTHORIZED) + } else { + Ok(Json(json!({ + "beta_settings": { + "plugins": state.workspace_plugins_enabled + } + }))) + } +} + async fn list_directory_connectors( State(state): State>, headers: HeaderMap, diff --git a/codex-rs/app-server/tests/suite/v2/command_exec.rs b/codex-rs/app-server/tests/suite/v2/command_exec.rs index d7645f13e3..211cec9355 100644 --- a/codex-rs/app-server/tests/suite/v2/command_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/command_exec.rs @@ -13,9 +13,17 @@ use codex_app_server_protocol::CommandExecResponse; use codex_app_server_protocol::CommandExecTerminalSize; use codex_app_server_protocol::CommandExecTerminateParams; use codex_app_server_protocol::CommandExecWriteParams; +use codex_app_server_protocol::FileSystemAccessMode; +use codex_app_server_protocol::FileSystemPath; +use codex_app_server_protocol::FileSystemSandboxEntry; +use codex_app_server_protocol::FileSystemSpecialPath; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::PermissionProfile; +use codex_app_server_protocol::PermissionProfileFileSystemPermissions; +use codex_app_server_protocol::PermissionProfileNetworkPermissions; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxPolicy; use pretty_assertions::assert_eq; use std::collections::HashMap; use tempfile::TempDir; @@ -57,6 +65,7 @@ async fn command_exec_without_streams_can_be_terminated() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; let terminate_request_id = mcp @@ -109,6 +118,7 @@ async fn command_exec_without_process_id_keeps_buffered_compatibility() -> Resul env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -167,6 +177,7 @@ async fn command_exec_env_overrides_merge_with_server_environment_and_support_un ])), size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -186,6 +197,159 @@ async fn command_exec_env_overrides_merge_with_server_environment_and_support_un Ok(()) } +#[tokio::test] +async fn command_exec_accepts_permission_profile() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf 'profile'".to_string(), + ], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: Some(root_read_only_permission_profile()), + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response, + CommandExecResponse { + exit_code: 0, + stdout: "profile".to_string(), + stderr: String::new(), + } + ); + + Ok(()) +} + +#[cfg(unix)] +#[tokio::test] +async fn command_exec_permission_profile_project_roots_use_command_cwd() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + let command_dir = codex_home.path().join("command-cwd"); + std::fs::create_dir(&command_dir)?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let mut permission_profile = root_read_only_permission_profile(); + let PermissionProfile::Managed { file_system, .. } = &mut permission_profile else { + panic!("root read-only helper should use managed permissions"); + }; + let PermissionProfileFileSystemPermissions::Restricted { entries, .. } = file_system else { + panic!("root read-only helper should use restricted filesystem permissions"); + }; + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::ProjectRoots { subpath: None }, + }, + access: FileSystemAccessMode::Write, + }); + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf child > child.txt && ! printf parent > ../parent.txt".to_string(), + ], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: Some("command-cwd".into()), + env: None, + size: None, + sandbox_policy: None, + permission_profile: Some(permission_profile), + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response.exit_code, 0, + "parent cwd write should fail under command project-root profile: {response:?}" + ); + assert_eq!( + std::fs::read_to_string(command_dir.join("child.txt"))?, + "child" + ); + assert!( + !codex_home.path().join("parent.txt").exists(), + "permissionProfile :project_roots write should not grant the server cwd when command cwd differs" + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_rejects_sandbox_policy_with_permission_profile() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec!["sh".to_string(), "-lc".to_string(), "true".to_string()], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: Some(SandboxPolicy::DangerFullAccess), + permission_profile: Some(root_read_only_permission_profile()), + }) + .await?; + + let error = mcp + .read_stream_until_error_message(RequestId::Integer(command_request_id)) + .await?; + assert_eq!( + error.error.message, + "`permissionProfile` cannot be combined with `sandboxPolicy`" + ); + + Ok(()) +} + #[tokio::test] async fn command_exec_rejects_disable_timeout_with_timeout_ms() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; @@ -209,6 +373,7 @@ async fn command_exec_rejects_disable_timeout_with_timeout_ms() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -246,6 +411,7 @@ async fn command_exec_rejects_disable_output_cap_with_output_bytes_cap() -> Resu env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -283,6 +449,7 @@ async fn command_exec_rejects_negative_timeout_ms() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -320,6 +487,7 @@ async fn command_exec_without_process_id_rejects_streaming() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -361,6 +529,7 @@ async fn command_exec_non_streaming_respects_output_cap() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -408,6 +577,7 @@ async fn command_exec_streaming_does_not_buffer_output() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -471,6 +641,7 @@ async fn command_exec_pipe_streams_output_and_accepts_write() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -546,6 +717,7 @@ async fn command_exec_tty_implies_streaming_and_reports_pty_output() -> Result<( env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -619,6 +791,7 @@ async fn command_exec_tty_supports_initial_size_and_resize() -> Result<()> { cols: 101, }), sandbox_policy: None, + permission_profile: None, }) .await?; @@ -888,6 +1061,21 @@ fn decode_delta_notification( serde_json::from_value(params).context("deserialize command/exec/outputDelta notification") } +fn root_read_only_permission_profile() -> PermissionProfile { + PermissionProfile::Managed { + network: PermissionProfileNetworkPermissions { enabled: false }, + file_system: PermissionProfileFileSystemPermissions::Restricted { + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }], + glob_scan_max_depth: None, + }, + } +} + async fn read_initialize_response( stream: &mut super::connection_handling_websocket::WsClient, request_id: i64, diff --git a/codex-rs/app-server/tests/suite/v2/compaction.rs b/codex-rs/app-server/tests/suite/v2/compaction.rs index 44b5dd6dc6..6db031b278 100644 --- a/codex-rs/app-server/tests/suite/v2/compaction.rs +++ b/codex-rs/app-server/tests/suite/v2/compaction.rs @@ -134,7 +134,6 @@ async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<() content: vec![ContentItem::OutputText { text: "REMOTE_COMPACT_SUMMARY".to_string(), }], - end_turn: None, phase: None, }, ResponseItem::Compaction { diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs index 456ae1577a..6581c1467a 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs @@ -1,6 +1,7 @@ use anyhow::Context; use anyhow::Result; use anyhow::bail; +use app_test_support::DISABLE_PLUGIN_STARTUP_TASKS_ARG; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::to_response; use base64::Engine; @@ -389,12 +390,13 @@ pub(super) async fn spawn_websocket_server_with_args( let mut cmd = Command::new(program); cmd.arg("--listen") .arg(listen_url) + .arg(DISABLE_PLUGIN_STARTUP_TASKS_ARG) .args(extra_args) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) .env("CODEX_HOME", codex_home) - .env("RUST_LOG", "debug"); + .env("RUST_LOG", "warn"); let mut process = cmd .kill_on_drop(true) .spawn() @@ -524,12 +526,13 @@ async fn run_websocket_server_to_completion_with_args( let mut cmd = Command::new(program); cmd.arg("--listen") .arg(listen_url) + .arg(DISABLE_PLUGIN_STARTUP_TASKS_ARG) .args(extra_args) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) .env("CODEX_HOME", codex_home) - .env("RUST_LOG", "debug"); + .env("RUST_LOG", "warn"); timeout(DEFAULT_READ_TIMEOUT, cmd.output()) .await .context("timed out waiting for websocket app-server to exit")? diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index 3f7e6989f8..57520a2d6c 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -1,8 +1,10 @@ use std::time::Duration; use anyhow::Result; +use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigReadResponse; use codex_app_server_protocol::ExperimentalFeature; @@ -14,8 +16,9 @@ use codex_app_server_protocol::ExperimentalFeatureStage; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; +use codex_config::LoaderOverrides; +use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::ConfigBuilder; -use codex_core::config_loader::LoaderOverrides; use codex_features::FEATURES; use codex_features::Stage; use pretty_assertions::assert_eq; @@ -24,6 +27,12 @@ use serde_json::json; use std::collections::BTreeMap; use tempfile::TempDir; use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); @@ -89,6 +98,63 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu Ok(()) } +#[tokio::test] +async fn experimental_feature_list_marks_apps_and_plugins_disabled_by_workspace_policy() +-> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#"chatgpt_base_url = "{}/backend-api/" +"#, + server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .plan_type("team"), + AuthCredentialsStoreMode::File, + )?; + Mock::given(method("GET")) + .and(path("/backend-api/accounts/account-123/settings")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200).set_body_string(r#"{"beta_settings":{"plugins":false}}"#), + ) + .mount(&server) + .await; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_experimental_feature_list_request(ExperimentalFeatureListParams::default()) + .await?; + + let actual = read_response::(&mut mcp, request_id).await?; + let apps = actual + .data + .iter() + .find(|feature| feature.name == "apps") + .expect("apps feature should be present"); + let plugins = actual + .data + .iter() + .find(|feature| feature.name == "plugins") + .expect("plugins feature should be present"); + assert!(!apps.enabled); + assert!(!plugins.enabled); + assert!(apps.default_enabled); + assert!(plugins.default_enabled); + Ok(()) +} + #[tokio::test] async fn experimental_feature_enablement_set_applies_to_global_and_thread_config_reads() -> Result<()> { @@ -129,18 +195,20 @@ async fn experimental_feature_enablement_set_does_not_override_user_config() -> let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join("config.toml"), - "[features]\napps = false\n", + "[features]\nmemories = false\n", )?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - let actual = - set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) - .await?; + let actual = set_experimental_feature_enablement( + &mut mcp, + BTreeMap::from([("memories".to_string(), true)]), + ) + .await?; assert_eq!( actual, ExperimentalFeatureEnablementSetResponse { - enablement: BTreeMap::from([("apps".to_string(), true)]), + enablement: BTreeMap::from([("memories".to_string(), true)]), } ); @@ -150,7 +218,7 @@ async fn experimental_feature_enablement_set_does_not_override_user_config() -> config .additional .get("features") - .and_then(|features| features.get("apps")), + .and_then(|features| features.get("memories")), Some(&json!(false)) ); @@ -168,6 +236,7 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re let actual = set_experimental_feature_enablement( &mut mcp, BTreeMap::from([ + ("memories".to_string(), true), ("plugins".to_string(), true), ("tool_search".to_string(), true), ("tool_suggest".to_string(), true), @@ -180,6 +249,7 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re actual, ExperimentalFeatureEnablementSetResponse { enablement: BTreeMap::from([ + ("memories".to_string(), true), ("plugins".to_string(), true), ("tool_search".to_string(), true), ("tool_suggest".to_string(), true), @@ -197,6 +267,13 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re .and_then(|features| features.get("apps")), Some(&json!(true)) ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("memories")), + Some(&json!(true)) + ); assert_eq!( config .additional @@ -285,9 +362,9 @@ async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() - error.message ); assert!( - error - .message - .contains("apps, plugins, tool_search, tool_suggest, tool_call_mcp_elicitation"), + error.message.contains( + "apps, memories, plugins, tool_search, tool_suggest, tool_call_mcp_elicitation" + ), "{}", error.message ); diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index 049256b602..6b1715dc7a 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -127,6 +127,8 @@ async fn external_agent_config_import_sends_completion_notification_after_pendin -> Result<()> { let codex_home = TempDir::new()?; std::fs::create_dir_all(codex_home.path().join(".claude"))?; + // This test only needs a pending non-local plugin import. Use an invalid + // source so the background completion path cannot make a real network clone. std::fs::write( codex_home.path().join(".claude").join("settings.json"), r#"{ @@ -135,7 +137,7 @@ async fn external_agent_config_import_sends_completion_notification_after_pendin }, "extraKnownMarketplaces": { "acme-tools": { - "source": "owner/debug-marketplace" + "source": "not a valid marketplace source" } } }"#, diff --git a/codex-rs/app-server/tests/suite/v2/fs.rs b/codex-rs/app-server/tests/suite/v2/fs.rs index 642844eb92..a780a51e0b 100644 --- a/codex-rs/app-server/tests/suite/v2/fs.rs +++ b/codex-rs/app-server/tests/suite/v2/fs.rs @@ -33,6 +33,7 @@ use std::process::Command; const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60); #[cfg(not(any(target_os = "macos", windows)))] const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); +const OPTIONAL_FS_CHANGE_TIMEOUT: Duration = Duration::from_secs(2); async fn initialized_mcp(codex_home: &TempDir) -> Result { let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -832,7 +833,7 @@ async fn maybe_fs_changed_notification( mcp: &mut McpProcess, ) -> Result> { match timeout( - DEFAULT_READ_TIMEOUT, + OPTIONAL_FS_CHANGE_TIMEOUT, mcp.read_stream_until_notification_message("fs/changed"), ) .await @@ -845,6 +846,14 @@ async fn maybe_fs_changed_notification( fn replace_file_atomically(path: &PathBuf, contents: &str) -> Result<()> { let temp_path = path.with_extension("lock"); std::fs::write(&temp_path, contents)?; + + #[cfg(windows)] + match std::fs::remove_file(path) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + std::fs::rename(temp_path, path)?; Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/marketplace_add.rs b/codex-rs/app-server/tests/suite/v2/marketplace_add.rs index cf3c57360f..5f470f617d 100644 --- a/codex-rs/app-server/tests/suite/v2/marketplace_add.rs +++ b/codex-rs/app-server/tests/suite/v2/marketplace_add.rs @@ -5,6 +5,7 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::MarketplaceAddParams; use codex_app_server_protocol::MarketplaceAddResponse; use codex_app_server_protocol::RequestId; +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::Duration; @@ -48,10 +49,10 @@ async fn marketplace_add_local_directory_source() -> Result<()> { installed_root, already_added, } = to_response(response)?; - let expected_root = source.canonicalize()?; + let expected_root = AbsolutePathBuf::from_absolute_path(source.canonicalize()?)?; assert_eq!(marketplace_name, "debug"); - assert_eq!(installed_root.as_path(), expected_root.as_path()); + assert_eq!(installed_root, expected_root); assert!(!already_added); assert_eq!( std::fs::read_to_string(installed_root.as_path().join("plugins/sample/marker.txt"))?, diff --git a/codex-rs/app-server/tests/suite/v2/marketplace_remove.rs b/codex-rs/app-server/tests/suite/v2/marketplace_remove.rs index fb144001e4..dc438499f3 100644 --- a/codex-rs/app-server/tests/suite/v2/marketplace_remove.rs +++ b/codex-rs/app-server/tests/suite/v2/marketplace_remove.rs @@ -10,7 +10,7 @@ use codex_app_server_protocol::MarketplaceRemoveResponse; use codex_app_server_protocol::RequestId; use codex_config::MarketplaceConfigUpdate; use codex_config::record_user_marketplace; -use codex_core::plugins::marketplace_install_root; +use codex_core_plugins::installed_marketplaces::marketplace_install_root; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; diff --git a/codex-rs/app-server/tests/suite/v2/marketplace_upgrade.rs b/codex-rs/app-server/tests/suite/v2/marketplace_upgrade.rs new file mode 100644 index 0000000000..8660497da5 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/marketplace_upgrade.rs @@ -0,0 +1,318 @@ +use std::path::Path; +use std::process::Command; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::MarketplaceUpgradeParams; +use codex_app_server_protocol::MarketplaceUpgradeResponse; +use codex_app_server_protocol::RequestId; +use codex_config::MarketplaceConfigUpdate; +use codex_config::record_user_marketplace; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +#[cfg(windows)] +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(25); +#[cfg(not(windows))] +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); +const INSTALLED_MARKETPLACES_DIR: &str = ".tmp/marketplaces"; + +fn run_git(cwd: &Path, args: &[&str]) -> Result { + let output = Command::new("git").current_dir(cwd).args(args).output()?; + if !output.status.success() { + anyhow::bail!( + "git {} failed in {}: {}", + args.join(" "), + cwd.display(), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn write_marketplace_files(root: &Path, marketplace_name: &str, marker: &str) -> Result<()> { + std::fs::create_dir_all(root.join(".agents/plugins"))?; + std::fs::write( + root.join(".agents/plugins/marketplace.json"), + format!(r#"{{"name":"{marketplace_name}","plugins":[]}}"#), + )?; + std::fs::write(root.join("marker.txt"), marker)?; + Ok(()) +} + +fn init_marketplace_repo(root: &Path, marketplace_name: &str, marker: &str) -> Result { + run_git(root, &["init"])?; + run_git(root, &["config", "user.email", "codex@example.com"])?; + run_git(root, &["config", "user.name", "Codex Tests"])?; + write_marketplace_files(root, marketplace_name, marker)?; + run_git(root, &["add", "."])?; + run_git(root, &["commit", "-m", "initial marketplace"])?; + run_git(root, &["rev-parse", "HEAD"]) +} + +fn commit_marketplace_marker(root: &Path, marker: &str) -> Result { + std::fs::write(root.join("marker.txt"), marker)?; + run_git(root, &["add", "marker.txt"])?; + run_git(root, &["commit", "-m", "update marker"])?; + run_git(root, &["rev-parse", "HEAD"]) +} + +fn configured_git_marketplace_update<'a>( + source: &'a str, + last_revision: Option<&'a str>, + ref_name: Option<&'a str>, +) -> MarketplaceConfigUpdate<'a> { + MarketplaceConfigUpdate { + last_updated: "2026-04-13T00:00:00Z", + last_revision, + source_type: "git", + source, + ref_name, + sparse_paths: &[], + } +} + +fn configured_local_marketplace_update(source: &str) -> MarketplaceConfigUpdate<'_> { + MarketplaceConfigUpdate { + last_updated: "2026-04-13T00:00:00Z", + last_revision: None, + source_type: "local", + source, + ref_name: None, + sparse_paths: &[], + } +} + +fn record_git_marketplace( + codex_home: &Path, + marketplace_name: &str, + source: &Path, + last_revision: &str, + ref_name: Option<&str>, +) -> Result<()> { + let source = source.display().to_string(); + record_user_marketplace( + codex_home, + marketplace_name, + &configured_git_marketplace_update(&source, Some(last_revision), ref_name), + )?; + Ok(()) +} + +fn disable_plugin_startup_tasks(codex_home: &Path) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + config_path, + format!("{config}\n[features]\nplugins = false\n"), + )?; + Ok(()) +} + +fn marketplace_install_root(codex_home: &Path) -> std::path::PathBuf { + codex_home.join(INSTALLED_MARKETPLACES_DIR) +} + +fn expected_installed_root(codex_home: &Path, marketplace_name: &str) -> Result { + AbsolutePathBuf::try_from( + marketplace_install_root(&codex_home.canonicalize()?).join(marketplace_name), + ) + .context("expected installed root should be absolute") +} + +async fn send_marketplace_upgrade( + mcp: &mut McpProcess, + marketplace_name: Option<&str>, +) -> Result { + let request_id = mcp + .send_marketplace_upgrade_request(MarketplaceUpgradeParams { + marketplace_name: marketplace_name.map(str::to_string), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +} + +#[tokio::test] +async fn marketplace_upgrade_all_configured_git_marketplaces() -> Result<()> { + let codex_home = TempDir::new()?; + let debug_source = TempDir::new()?; + let tools_source = TempDir::new()?; + let debug_old_revision = init_marketplace_repo(debug_source.path(), "debug", "debug old")?; + let tools_old_revision = init_marketplace_repo(tools_source.path(), "tools", "tools old")?; + let debug_new_revision = commit_marketplace_marker(debug_source.path(), "debug new")?; + let tools_new_revision = commit_marketplace_marker(tools_source.path(), "tools new")?; + record_git_marketplace( + codex_home.path(), + "debug", + debug_source.path(), + &debug_old_revision, + Some(&debug_new_revision), + )?; + record_git_marketplace( + codex_home.path(), + "tools", + tools_source.path(), + &tools_old_revision, + Some(&tools_new_revision), + )?; + disable_plugin_startup_tasks(codex_home.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let debug_root = expected_installed_root(codex_home.path(), "debug")?; + let tools_root = expected_installed_root(codex_home.path(), "tools")?; + let response = send_marketplace_upgrade(&mut mcp, /*marketplace_name*/ None).await?; + + assert_eq!( + response, + MarketplaceUpgradeResponse { + selected_marketplaces: vec!["debug".to_string(), "tools".to_string()], + upgraded_roots: vec![debug_root.clone(), tools_root.clone()], + errors: Vec::new(), + } + ); + assert_eq!( + std::fs::read_to_string(debug_root.as_path().join("marker.txt"))?, + "debug new" + ); + assert_eq!( + std::fs::read_to_string(tools_root.as_path().join("marker.txt"))?, + "tools new" + ); + let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(config.contains(&debug_new_revision)); + assert!(config.contains(&tools_new_revision)); + Ok(()) +} + +#[tokio::test] +async fn marketplace_upgrade_named_marketplace_only() -> Result<()> { + let codex_home = TempDir::new()?; + let debug_source = TempDir::new()?; + let tools_source = TempDir::new()?; + let debug_old_revision = init_marketplace_repo(debug_source.path(), "debug", "debug old")?; + let tools_old_revision = init_marketplace_repo(tools_source.path(), "tools", "tools old")?; + commit_marketplace_marker(debug_source.path(), "debug new")?; + commit_marketplace_marker(tools_source.path(), "tools new")?; + record_git_marketplace( + codex_home.path(), + "debug", + debug_source.path(), + &debug_old_revision, + /*ref_name*/ None, + )?; + record_git_marketplace( + codex_home.path(), + "tools", + tools_source.path(), + &tools_old_revision, + /*ref_name*/ None, + )?; + disable_plugin_startup_tasks(codex_home.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let tools_root = expected_installed_root(codex_home.path(), "tools")?; + let response = send_marketplace_upgrade(&mut mcp, Some("tools")).await?; + + assert_eq!( + response, + MarketplaceUpgradeResponse { + selected_marketplaces: vec!["tools".to_string()], + upgraded_roots: vec![tools_root.clone()], + errors: Vec::new(), + } + ); + assert_eq!( + std::fs::read_to_string(tools_root.as_path().join("marker.txt"))?, + "tools new" + ); + assert!( + !marketplace_install_root(codex_home.path()) + .join("debug") + .exists() + ); + Ok(()) +} + +#[tokio::test] +async fn marketplace_upgrade_returns_empty_roots_when_already_up_to_date() -> Result<()> { + let codex_home = TempDir::new()?; + let source = TempDir::new()?; + let old_revision = init_marketplace_repo(source.path(), "debug", "debug old")?; + commit_marketplace_marker(source.path(), "debug new")?; + record_git_marketplace( + codex_home.path(), + "debug", + source.path(), + &old_revision, + /*ref_name*/ None, + )?; + disable_plugin_startup_tasks(codex_home.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let first_response = send_marketplace_upgrade(&mut mcp, Some("debug")).await?; + assert!(first_response.errors.is_empty()); + + let response = send_marketplace_upgrade(&mut mcp, Some("debug")).await?; + + assert_eq!( + response, + MarketplaceUpgradeResponse { + selected_marketplaces: vec!["debug".to_string()], + upgraded_roots: Vec::new(), + errors: Vec::new(), + } + ); + Ok(()) +} + +#[tokio::test] +async fn marketplace_upgrade_rejects_unknown_or_non_git_marketplace() -> Result<()> { + let codex_home = TempDir::new()?; + let local_source = TempDir::new()?; + record_user_marketplace( + codex_home.path(), + "local-only", + &configured_local_marketplace_update(&local_source.path().display().to_string()), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + for marketplace_name in ["missing", "local-only"] { + let request_id = mcp + .send_marketplace_upgrade_request(MarketplaceUpgradeParams { + marketplace_name: Some(marketplace_name.to_string()), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert_eq!( + err.error.message, + format!("marketplace `{marketplace_name}` is not configured as a Git marketplace"), + ); + } + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index a347d87fc7..3b1a495576 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -20,10 +20,10 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_arg0::Arg0DispatchPaths; +use codex_config::CloudRequirementsLoader; +use codex_config::LoaderOverrides; use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::ConfigBuilder; -use codex_core::config_loader::CloudRequirementsLoader; -use codex_core::config_loader::LoaderOverrides; use codex_exec_server::EnvironmentManager; use codex_feedback::CodexFeedback; use codex_protocol::protocol::SessionSource; diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 4ab21ffc52..776424cc99 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -19,6 +19,7 @@ mod fs; mod initialize; mod marketplace_add; mod marketplace_remove; +mod marketplace_upgrade; mod mcp_resource; mod mcp_server_elicitation; mod mcp_server_status; @@ -33,6 +34,8 @@ mod plugin_read; mod plugin_uninstall; mod rate_limits; mod realtime_conversation; +#[cfg(debug_assertions)] +mod remote_thread_store; mod request_permissions; mod request_user_input; mod review; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 3555dd745b..88403d8919 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -4,6 +4,7 @@ use std::sync::Mutex as StdMutex; use std::time::Duration; use anyhow::Result; +use anyhow::bail; use app_test_support::ChatGptAuthFixture; use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; @@ -44,6 +45,13 @@ use tempfile::TempDir; use tokio::net::TcpListener; use tokio::task::JoinHandle; use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; +use wiremock::matchers::query_param; // Plugin install tests wait on connector discovery after the install response path // starts, which is noticeably slower on Windows CI. @@ -137,8 +145,7 @@ async fn plugin_install_rejects_multiple_install_sources() -> Result<()> { } #[tokio::test] -async fn plugin_install_rejects_remote_marketplace_until_remote_install_is_supported() -> Result<()> -{ +async fn plugin_install_rejects_remote_marketplace_when_remote_plugin_is_disabled() -> Result<()> { let codex_home = TempDir::new()?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -146,7 +153,208 @@ async fn plugin_install_rejects_remote_marketplace_until_remote_install_is_suppo let request_id = mcp .send_plugin_install_request(PluginInstallParams { marketplace_path: None, - remote_marketplace_name: Some("openai-curated".to_string()), + remote_marketplace_name: Some("chatgpt-global".to_string()), + plugin_name: "plugins~Plugin_sample".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("remote plugin install is not enabled") + ); + assert!(err.error.message.contains("chatgpt-global")); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_writes_remote_plugin_to_cloud_when_remote_plugin_enabled() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let detail_body = r#"{ + "id": "plugins~Plugin_linear", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": { + "short_description": "Plan and track work" + }, + "skills": [] + } +}"#; + let empty_installed_body = r#"{ + "plugins": [], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/plugins~Plugin_linear")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(detail_body)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "GLOBAL")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(empty_installed_body)) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path( + "/backend-api/ps/plugins/plugins~Plugin_linear/install", + )) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(r#"{"id":"plugins~Plugin_linear","enabled":true}"#), + ) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: None, + remote_marketplace_name: Some("chatgpt-global".to_string()), + plugin_name: "plugins~Plugin_linear".to_string(), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginInstallResponse = to_response(response)?; + + assert_eq!( + response, + PluginInstallResponse { + auth_policy: PluginAuthPolicy::OnUse, + apps_needing_auth: Vec::new(), + } + ); + wait_for_remote_plugin_request_count( + &server, + "POST", + "/ps/plugins/plugins~Plugin_linear/install", + /*expected_count*/ 1, + ) + .await?; + Ok(()) +} + +#[tokio::test] +async fn plugin_install_rejects_invalid_remote_plugin_name() -> Result<()> { + let codex_home = TempDir::new()?; + write_remote_plugin_catalog_config(codex_home.path(), "https://example.invalid/backend-api/")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: None, + remote_marketplace_name: Some("chatgpt-global".to_string()), + plugin_name: "linear/../../oops".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("invalid remote plugin id")); + assert!( + err.error + .message + .contains("only ASCII letters, digits, `_`, `-`, and `~` are allowed") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_rejects_when_workspace_codex_plugins_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let server = MockServer::start().await; + write_plugins_enabled_config_with_base_url( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .plan_type("team"), + AuthCredentialsStoreMode::File, + )?; + write_plugin_marketplace( + repo_root.path(), + "debug", + "sample-plugin", + "./sample-plugin", + /*install_policy*/ None, + /*auth_policy*/ None, + )?; + write_plugin_source(repo_root.path(), "sample-plugin", &[])?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + + Mock::given(method("GET")) + .and(path("/backend-api/accounts/account-123/settings")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200).set_body_string(r#"{"beta_settings":{"plugins":false}}"#), + ) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, plugin_name: "sample-plugin".to_string(), }) .await?; @@ -161,9 +369,8 @@ async fn plugin_install_rejects_remote_marketplace_until_remote_install_is_suppo assert!( err.error .message - .contains("remote plugin install is not supported yet") + .contains("Codex plugins are disabled for this workspace") ); - assert!(err.error.message.contains("openai-curated")); Ok(()) } @@ -766,6 +973,22 @@ connectors = true ) } +fn write_plugins_enabled_config_with_base_url( + codex_home: &std::path::Path, + base_url: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#"chatgpt_base_url = "{base_url}" + +[features] +plugins = true +"#, + ), + ) +} + fn write_analytics_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> { std::fs::write( codex_home.join("config.toml"), @@ -773,6 +996,56 @@ fn write_analytics_config(codex_home: &std::path::Path, base_url: &str) -> std:: ) } +fn write_remote_plugin_catalog_config( + codex_home: &std::path::Path, + base_url: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +chatgpt_base_url = "{base_url}" + +[features] +plugins = true +remote_plugin = true +"# + ), + ) +} + +async fn wait_for_remote_plugin_request_count( + server: &MockServer, + method_name: &str, + path_suffix: &str, + expected_count: usize, +) -> Result<()> { + timeout(DEFAULT_TIMEOUT, async { + loop { + let Some(requests) = server.received_requests().await else { + bail!("wiremock did not record requests"); + }; + let request_count = requests + .iter() + .filter(|request| { + request.method == method_name && request.url.path().ends_with(path_suffix) + }) + .count(); + if request_count == expected_count { + return Ok::<(), anyhow::Error>(()); + } + if request_count > expected_count { + bail!( + "expected exactly {expected_count} {method_name} {path_suffix} requests, got {request_count}" + ); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await??; + Ok(()) +} + fn write_plugin_marketplace( repo_root: &std::path::Path, marketplace_name: &str, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index 9718761e33..8735c20ff6 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -30,7 +30,7 @@ use wiremock::matchers::method; use wiremock::matchers::path; use wiremock::matchers::query_param; -const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567"; const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1"; const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json"; @@ -45,6 +45,22 @@ plugins = true ) } +fn write_plugins_enabled_config_with_base_url( + codex_home: &std::path::Path, + base_url: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#"chatgpt_base_url = "{base_url}" + +[features] +plugins = true +"#, + ), + ) +} + #[tokio::test] async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Result<()> { let codex_home = TempDir::new()?; @@ -244,6 +260,158 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ Ok(()) } +#[tokio::test] +async fn plugin_list_returns_empty_when_workspace_codex_plugins_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let server = MockServer::start().await; + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + write_plugins_enabled_config_with_base_url( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .plan_type("team"), + AuthCredentialsStoreMode::File, + )?; + + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./demo-plugin" + } + } + ] +}"#, + )?; + + Mock::given(method("GET")) + .and(path("/backend-api/accounts/account-123/settings")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200).set_body_string(r#"{"beta_settings":{"plugins":false}}"#), + ) + .mount(&server) + .await; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert_eq!( + response, + PluginListResponse { + marketplaces: Vec::new(), + marketplace_load_errors: Vec::new(), + featured_plugin_ids: Vec::new(), + } + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_reuses_cached_workspace_codex_plugins_setting() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let server = MockServer::start().await; + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + std::fs::create_dir_all(repo_root.path().join("demo-plugin/.codex-plugin"))?; + write_plugins_enabled_config_with_base_url( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .plan_type("team"), + AuthCredentialsStoreMode::File, + )?; + + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "local-marketplace", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./demo-plugin" + } + } + ] +}"#, + )?; + std::fs::write( + repo_root + .path() + .join("demo-plugin/.codex-plugin/plugin.json"), + r#"{"name":"demo-plugin"}"#, + )?; + + Mock::given(method("GET")) + .and(path("/backend-api/accounts/account-123/settings")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200).set_body_string(r#"{"beta_settings":{"plugins":true}}"#), + ) + .mount(&server) + .await; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + for _ in 0..2 { + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + assert_eq!(response.marketplaces.len(), 1); + assert_eq!(response.marketplaces[0].name, "local-marketplace"); + } + + wait_for_workspace_settings_request_count(&server, /*expected_count*/ 1).await?; + Ok(()) +} + #[tokio::test] async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverable_plugins() -> Result<()> { @@ -898,7 +1066,7 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { .join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE); { - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_with_plugin_startup_tasks(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; wait_for_path_exists(&marker_path).await?; @@ -934,7 +1102,7 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); { - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_with_plugin_startup_tasks(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; } @@ -943,6 +1111,335 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let global_directory_body = r#"{ + "plugins": [ + { + "id": "plugins~Plugin_linear", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": { + "short_description": "Plan and track work", + "capabilities": ["Read", "Write"], + "logo_url": "https://example.com/linear.png", + "screenshot_urls": ["https://example.com/linear-shot.png"] + }, + "skills": [] + } + } + ], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + let empty_page_body = r#"{ + "plugins": [], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + let global_installed_body = r#"{ + "plugins": [ + { + "id": "plugins~Plugin_linear", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": { + "short_description": "Plan and track work", + "capabilities": ["Read", "Write"], + "logo_url": "https://example.com/linear.png", + "screenshot_urls": ["https://example.com/linear-shot.png"] + }, + "skills": [] + }, + "enabled": true, + "disabled_skill_names": [] + } + ], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/list")) + .and(query_param("scope", "GLOBAL")) + .and(query_param("limit", "200")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(global_directory_body)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/list")) + .and(query_param("scope", "WORKSPACE")) + .and(query_param("limit", "200")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(empty_page_body)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "GLOBAL")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(global_installed_body)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "WORKSPACE")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(empty_page_body)) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { cwds: None }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + let remote_marketplace = response + .marketplaces + .into_iter() + .find(|marketplace| marketplace.name == "chatgpt-global") + .expect("expected ChatGPT remote marketplace"); + assert_eq!(remote_marketplace.path, None); + assert_eq!( + remote_marketplace + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("ChatGPT Plugins") + ); + assert_eq!(remote_marketplace.plugins.len(), 1); + assert_eq!(remote_marketplace.plugins[0].id, "plugins~Plugin_linear"); + assert_eq!(remote_marketplace.plugins[0].name, "linear"); + assert_eq!(remote_marketplace.plugins[0].source, PluginSource::Remote); + assert_eq!(remote_marketplace.plugins[0].installed, true); + assert_eq!(remote_marketplace.plugins[0].enabled, true); + assert_eq!( + remote_marketplace.plugins[0] + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("Linear") + ); + assert_eq!(response.featured_plugin_ids, Vec::::new()); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_remote_marketplace_replaces_local_marketplace_with_same_name() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + let local_plugin_root = codex_home + .path() + .join(".agents/plugins/plugins/local-linear/.codex-plugin"); + std::fs::create_dir_all(&local_plugin_root)?; + std::fs::write( + codex_home.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "chatgpt-global", + "plugins": [ + { + "name": "local-linear", + "source": { + "source": "local", + "path": "./plugins/local-linear" + } + } + ] +}"#, + )?; + std::fs::write( + local_plugin_root.join("plugin.json"), + r#"{"name":"local-linear"}"#, + )?; + + let global_directory_body = r#"{ + "plugins": [ + { + "id": "plugins~Plugin_linear", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": {}, + "skills": [] + } + } + ], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + let empty_page_body = r#"{ + "plugins": [], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + for (scope, body) in [ + ("GLOBAL", global_directory_body), + ("WORKSPACE", empty_page_body), + ] { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/list")) + .and(query_param("scope", scope)) + .and(query_param("limit", "200")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .mount(&server) + .await; + } + for scope in ["GLOBAL", "WORKSPACE"] { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", scope)) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(empty_page_body)) + .mount(&server) + .await; + } + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { cwds: None }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + let matching_marketplaces = response + .marketplaces + .iter() + .filter(|marketplace| marketplace.name == "chatgpt-global") + .collect::>(); + + assert_eq!(matching_marketplaces.len(), 1); + assert_eq!(matching_marketplaces[0].path, None); + assert_eq!(matching_marketplaces[0].plugins.len(), 1); + assert_eq!( + matching_marketplaces[0].plugins[0].source, + PluginSource::Remote + ); + assert_eq!(matching_marketplaces[0].plugins[0].name, "linear"); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_does_not_fetch_remote_marketplaces_when_plugins_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = false +remote_plugin = true +"#, + server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { cwds: None }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert!(response.marketplaces.is_empty()); + wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?; + Ok(()) +} + #[tokio::test] async fn plugin_list_fetches_featured_plugin_ids_without_chatgpt_auth() -> Result<()> { let codex_home = TempDir::new()?; @@ -993,7 +1490,7 @@ async fn plugin_list_uses_warmed_featured_plugin_ids_cache_on_first_request() -> .mount(&server) .await; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = McpProcess::new_with_plugin_startup_tasks(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; wait_for_featured_plugin_request_count(&server, /*expected_count*/ 1).await?; @@ -1022,6 +1519,14 @@ async fn wait_for_featured_plugin_request_count( wait_for_remote_plugin_request_count(server, "/plugins/featured", expected_count).await } +async fn wait_for_workspace_settings_request_count( + server: &MockServer, + expected_count: usize, +) -> Result<()> { + wait_for_remote_plugin_request_count(server, "/accounts/account-123/settings", expected_count) + .await +} + async fn wait_for_remote_plugin_request_count( server: &MockServer, path_suffix: &str, @@ -1108,6 +1613,24 @@ enabled = true ) } +fn write_remote_plugin_catalog_config( + codex_home: &std::path::Path, + base_url: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +chatgpt_base_url = "{base_url}" + +[features] +plugins = true +remote_plugin = true +"# + ), + ) +} + fn write_openai_curated_marketplace( codex_home: &std::path::Path, plugin_names: &[&str], diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index e79a72a920..5360c381d8 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -22,6 +22,7 @@ use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginSource; use codex_app_server_protocol::RequestId; use codex_config::types::AuthCredentialsStoreMode; use codex_utils_absolute_path::AbsolutePathBuf; @@ -42,6 +43,13 @@ use tempfile::TempDir; use tokio::net::TcpListener; use tokio::task::JoinHandle; use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; +use wiremock::matchers::query_param; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); @@ -106,7 +114,7 @@ async fn plugin_read_rejects_multiple_read_sources() -> Result<()> { } #[tokio::test] -async fn plugin_read_rejects_remote_marketplace_until_remote_read_is_supported() -> Result<()> { +async fn plugin_read_rejects_remote_marketplace_when_remote_plugin_is_disabled() -> Result<()> { let codex_home = TempDir::new()?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -114,7 +122,7 @@ async fn plugin_read_rejects_remote_marketplace_until_remote_read_is_supported() let request_id = mcp .send_plugin_read_request(PluginReadParams { marketplace_path: None, - remote_marketplace_name: Some("openai-curated".to_string()), + remote_marketplace_name: Some("chatgpt-global".to_string()), plugin_name: "sample-plugin".to_string(), }) .await?; @@ -129,9 +137,340 @@ async fn plugin_read_rejects_remote_marketplace_until_remote_read_is_supported() assert!( err.error .message - .contains("remote plugin read is not supported yet") + .contains("remote plugin read is not enabled") ); - assert!(err.error.message.contains("openai-curated")); + assert!(err.error.message.contains("chatgpt-global")); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let detail_body = r#"{ + "id": "plugins~Plugin_linear", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": { + "short_description": "Plan and track work", + "capabilities": ["Read", "Write"], + "logo_url": "https://example.com/linear.png", + "screenshot_urls": ["https://example.com/linear-shot.png"] + }, + "skills": [ + { + "name": "plan-work", + "description": "Plan work from Linear issues", + "plugin_release_skill_id": "skill-1", + "interface": { + "display_name": "Plan Work", + "short_description": "Create a plan from issues" + } + } + ] + } +}"#; + let installed_body = r#"{ + "plugins": [ + { + "id": "plugins~Plugin_linear", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": { + "short_description": "Plan and track work", + "capabilities": ["Read", "Write"], + "logo_url": "https://example.com/linear.png", + "screenshot_urls": ["https://example.com/linear-shot.png"] + }, + "skills": [ + { + "name": "plan-work", + "description": "Plan work from Linear issues", + "plugin_release_skill_id": "skill-1", + "interface": { + "display_name": "Plan Work", + "short_description": "Create a plan from issues" + } + } + ] + }, + "enabled": false, + "disabled_skill_names": ["plan-work"] + } + ], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/plugins~Plugin_linear")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(detail_body)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "GLOBAL")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(installed_body)) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: None, + remote_marketplace_name: Some("chatgpt-global".to_string()), + plugin_name: "plugins~Plugin_linear".to_string(), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginReadResponse = to_response(response)?; + + assert_eq!(response.plugin.marketplace_name, "chatgpt-global"); + assert_eq!(response.plugin.marketplace_path, None); + assert_eq!(response.plugin.summary.source, PluginSource::Remote); + assert_eq!(response.plugin.summary.id, "plugins~Plugin_linear"); + assert_eq!(response.plugin.summary.name, "linear"); + assert_eq!(response.plugin.summary.installed, true); + assert_eq!(response.plugin.summary.enabled, false); + assert_eq!( + response.plugin.description.as_deref(), + Some("Track work in Linear") + ); + assert_eq!(response.plugin.skills.len(), 1); + assert_eq!(response.plugin.skills[0].name, "plan-work"); + assert_eq!(response.plugin.skills[0].path, None); + assert_eq!(response.plugin.skills[0].enabled, false); + assert_eq!(response.plugin.apps.len(), 0); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_maps_missing_remote_plugin_to_invalid_request() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/plugins~Plugin_missing")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(404).set_body_string(r#"{"detail":"not found"}"#)) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: None, + remote_marketplace_name: Some("chatgpt-global".to_string()), + plugin_name: "plugins~Plugin_missing".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("read remote plugin details: remote plugin catalog request") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_rejects_remote_marketplace_when_plugins_are_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = false +remote_plugin = true +"#, + server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: None, + remote_marketplace_name: Some("chatgpt-global".to_string()), + plugin_name: "linear".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!( + err.error + .message + .contains("remote plugin read is not enabled") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_rejects_invalid_remote_plugin_name() -> Result<()> { + let codex_home = TempDir::new()?; + write_remote_plugin_catalog_config(codex_home.path(), "https://example.invalid/backend-api/")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: None, + remote_marketplace_name: Some("chatgpt-global".to_string()), + plugin_name: "linear/../../oops".to_string(), + }) + .await?; + + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("invalid remote plugin id")); + assert!( + err.error + .message + .contains("only ASCII letters, digits, `_`, `-`, and `~` are allowed") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_returns_canonical_openai_curated_marketplace_name() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + write_plugin_marketplace( + repo_root.path(), + "openai-curated", + "demo-plugin", + "./demo-plugin", + )?; + std::fs::create_dir_all(repo_root.path().join("demo-plugin/.codex-plugin"))?; + std::fs::write( + repo_root + .path() + .join("demo-plugin/.codex-plugin/plugin.json"), + r#"{ + "name": "demo-plugin", + "description": "OpenAI curated plugin" +}"#, + )?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = true + +[plugins."demo-plugin@openai-curated"] +enabled = true +"#, + )?; + write_installed_plugin(&codex_home, "openai-curated", "demo-plugin")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: Some(marketplace_path.clone()), + remote_marketplace_name: None, + plugin_name: "demo-plugin".to_string(), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginReadResponse = to_response(response)?; + + assert_eq!(response.plugin.marketplace_name, "openai-curated"); + assert_eq!(response.plugin.marketplace_path, Some(marketplace_path)); + assert_eq!(response.plugin.summary.id, "demo-plugin@openai-curated"); + assert_eq!(response.plugin.summary.name, "demo-plugin"); Ok(()) } @@ -283,7 +622,7 @@ enabled = true let response: PluginReadResponse = to_response(response)?; assert_eq!(response.plugin.marketplace_name, "codex-curated"); - assert_eq!(response.plugin.marketplace_path, marketplace_path); + assert_eq!(response.plugin.marketplace_path, Some(marketplace_path)); assert_eq!(response.plugin.summary.id, "demo-plugin@codex-curated"); assert_eq!(response.plugin.summary.name, "demo-plugin"); assert_eq!( @@ -858,6 +1197,24 @@ connectors = true ) } +fn write_remote_plugin_catalog_config( + codex_home: &std::path::Path, + base_url: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +chatgpt_base_url = "{base_url}" + +[features] +plugins = true +remote_plugin = true +"# + ), + ) +} + fn write_plugin_marketplace( repo_root: &std::path::Path, marketplace_name: &str, diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index 793efbe1e1..62ba19cc4f 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -72,6 +72,7 @@ use wiremock::matchers::path; use wiremock::matchers::path_regex; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); +const DELEGATED_SHELL_TOOL_TIMEOUT_MS: u64 = 30_000; const STARTUP_CONTEXT_HEADER: &str = "Startup context from Codex."; const V2_STEERING_ACKNOWLEDGEMENT: &str = "This was sent to steer the previous background agent task."; @@ -280,7 +281,7 @@ impl RealtimeE2eHarness { )?; let mut mcp = McpProcess::new(codex_home.path()).await?; - mcp.initialize().await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; login_with_api_key(&mut mcp, "sk-test-key").await?; let thread_start_request_id = mcp @@ -344,10 +345,16 @@ impl RealtimeE2eHarness { /// Returns the nth JSON message app-server wrote to the fake Realtime API /// sideband websocket. async fn sideband_outbound_request(&self, request_index: usize) -> Value { - self.realtime_server - .wait_for_request(/*connection_index*/ 0, request_index) - .await - .body_json() + timeout( + DEFAULT_TIMEOUT, + self.realtime_server + .wait_for_request(/*connection_index*/ 0, request_index), + ) + .await + .unwrap_or_else(|_| { + panic!("timed out waiting for realtime sideband request {request_index}") + }) + .body_json() } async fn append_audio(&mut self, thread_id: String) -> Result<()> { @@ -533,7 +540,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { )?; let mut mcp = McpProcess::new(codex_home.path()).await?; - mcp.initialize().await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; login_with_api_key(&mut mcp, "sk-test-key").await?; let thread_start_request_id = mcp @@ -782,7 +789,7 @@ async fn realtime_text_output_modality_requests_text_output_and_final_transcript )?; let mut mcp = McpProcess::new(codex_home.path()).await?; - mcp.initialize().await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; login_with_api_key(&mut mcp, "sk-test-key").await?; let thread_start_request_id = mcp @@ -884,7 +891,7 @@ async fn realtime_list_voices_returns_supported_names() -> Result<()> { )?; let mut mcp = McpProcess::new(codex_home.path()).await?; - mcp.initialize().await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_thread_realtime_list_voices_request(ThreadRealtimeListVoicesParams {}) @@ -956,7 +963,7 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> { )?; let mut mcp = McpProcess::new(codex_home.path()).await?; - mcp.initialize().await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; login_with_api_key(&mut mcp, "sk-test-key").await?; let thread_start_request_id = mcp @@ -1052,7 +1059,7 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { )?; let mut mcp = McpProcess::new(codex_home.path()).await?; - mcp.initialize().await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; login_with_api_key(&mut mcp, "sk-test-key").await?; let thread_start_request_id = mcp @@ -1781,7 +1788,9 @@ async fn webrtc_v2_tool_call_delegated_turn_can_execute_shell_tool() -> Result<( create_shell_command_sse_response( realtime_tool_ok_command(), /*workdir*/ None, - Some(5000), + // Windows CI can spend several seconds starting the nested PowerShell command. This + // test verifies delegated shell-tool plumbing, not timeout enforcement. + Some(DELEGATED_SHELL_TOOL_TIMEOUT_MS), "shell_call", )?, create_final_assistant_message_sse_response("shell tool finished")?, @@ -1965,7 +1974,7 @@ async fn realtime_webrtc_start_surfaces_backend_error() -> Result<()> { )?; let mut mcp = McpProcess::new(codex_home.path()).await?; - mcp.initialize().await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; login_with_api_key(&mut mcp, "sk-test-key").await?; // Phase 2: start a normal app-server thread and request realtime over WebRTC. @@ -2026,7 +2035,7 @@ async fn realtime_conversation_requires_feature_flag() -> Result<()> { )?; let mut mcp = McpProcess::new(codex_home.path()).await?; - mcp.initialize().await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let thread_start_request_id = mcp .send_thread_start_request(ThreadStartParams::default()) diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs new file mode 100644 index 0000000000..27160bd787 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -0,0 +1,259 @@ +//! Regression coverage for app-server thread operations backed by a non-local +//! `ThreadStore`. +//! +//! The app-server startup path should honor `experimental_thread_store` +//! by routing all thread persistence through the configured store. This suite uses +//! the thread-store crate's test-only in-memory store, which exercises the same +//! config-driven selection path as a remote store without requiring the real gRPC +//! service. +//! +//! The important failure mode is accidentally materializing local persistence +//! while a non-local store is configured. After `thread/start` and a simple turn, +//! the temporary `codex_home` must not contain rollout session files or sqlite +//! state files. This does not observe read-only probes that leave no artifact; it +//! is a stop-gap that prevents additional local persistence writes from slipping +//! in unnoticed. + +use std::collections::BTreeSet; +use std::path::Path; +use std::sync::Arc; + +use anyhow::Result; +use app_test_support::create_mock_responses_server_repeating_assistant; +use codex_app_server::in_process; +use codex_app_server::in_process::InProcessServerEvent; +use codex_app_server::in_process::InProcessStartArgs; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_arg0::Arg0DispatchPaths; +use codex_config::CloudRequirementsLoader; +use codex_config::LoaderOverrides; +use codex_config::NoopThreadConfigLoader; +use codex_core::config::ConfigBuilder; +use codex_exec_server::EnvironmentManager; +use codex_feedback::CodexFeedback; +use codex_protocol::protocol::SessionSource; +use codex_thread_store::InMemoryThreadStore; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; +use uuid::Uuid; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn thread_start_with_non_local_thread_store_does_not_create_local_persistence() -> Result<()> +{ + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + let store_id = Uuid::new_v4().to_string(); + // Plugin startup warmups may create `.tmp` under codex_home. Disable them + // here so this regression stays focused on thread persistence artifacts. + create_config_toml_with_thread_store(codex_home.path(), &server.uri(), &store_id)?; + + let loader_overrides = LoaderOverrides::without_managed_config_for_tests(); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .loader_overrides(loader_overrides.clone()) + .build() + .await?; + + let thread_store = InMemoryThreadStore::for_id(store_id.clone()); + let _in_memory_store = InMemoryThreadStoreId { store_id }; + + let mut client = in_process::start(InProcessStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config: Arc::new(config), + cli_overrides: Vec::new(), + loader_overrides, + cloud_requirements: CloudRequirementsLoader::default(), + thread_config_loader: Arc::new(NoopThreadConfigLoader), + feedback: CodexFeedback::new(), + log_db: None, + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), + config_warnings: Vec::new(), + session_source: SessionSource::Cli, + enable_codex_api_key_env: false, + initialize: InitializeParams { + client_info: ClientInfo { + name: "codex-app-server-tests".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + capabilities: None, + }, + channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }) + .await?; + + let response = client + .request(ClientRequest::ThreadStart { + request_id: RequestId::Integer(1), + params: ThreadStartParams::default(), + }) + .await? + .expect("thread/start should succeed"); + let ThreadStartResponse { thread, .. } = + serde_json::from_value(response).expect("thread/start response should parse"); + assert_eq!(thread.path, None); + + client + .request(ClientRequest::TurnStart { + request_id: RequestId::Integer(2), + params: TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }, + }) + .await? + .expect("turn/start should succeed"); + + timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let Some(event) = client.next_event().await else { + anyhow::bail!("in-process app-server stopped before turn/completed"); + }; + if let InProcessServerEvent::ServerNotification(ServerNotification::TurnCompleted( + completed, + )) = event + && completed.thread_id == thread.id + { + return Ok::<(), anyhow::Error>(()); + } + } + }) + .await??; + + client.shutdown().await?; + + let calls = thread_store.calls().await; + assert_eq!(calls.create_thread, 1); + assert!( + calls.append_items > 0, + "turn/start should append rollout items through the injected store" + ); + assert!( + calls.flush_thread > 0, + "turn completion should flush through the injected store" + ); + + assert_no_local_persistence_artifacts(codex_home.path())?; + + Ok(()) +} + +fn assert_no_local_persistence_artifacts(codex_home: &Path) -> Result<()> { + // These are the observable tripwires for accidental local persistence. If a + // future code path constructs a local rollout/session store or opens the + // local thread sqlite database, it should leave one of these artifacts in + // the isolated test codex_home. + assert!( + !codex_home.join("sessions").exists(), + "non-local thread persistence should not create local rollout sessions" + ); + assert!( + !codex_home.join("archived_sessions").exists(), + "non-local thread persistence should not create archived rollout sessions" + ); + assert!( + !codex_state::state_db_path(codex_home).exists(), + "non-local thread persistence should not create local thread sqlite" + ); + + let sqlite_artifacts = std::fs::read_dir(codex_home)? + .filter_map(std::result::Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name.ends_with(".sqlite") + || name.ends_with(".sqlite-shm") + || name.ends_with(".sqlite-wal") + }) + }) + .collect::>(); + + assert!( + sqlite_artifacts.is_empty(), + "non-local thread persistence should not create sqlite artifacts: {sqlite_artifacts:?}" + ); + let mut entries = codex_home_entries(codex_home)?; + // Bazel test runs may initialize shell snapshot storage under codex_home. + // That is not thread persistence; keep the assertion focused on rollout, + // session, sqlite, and other unexpected thread-store artifacts. + entries.remove("shell_snapshots"); + assert_eq!( + entries, + BTreeSet::from([ + "config.toml".to_string(), + "installation_id".to_string(), + "memories".to_string(), + "skills".to_string(), + ]), + "non-local thread persistence should not create unexpected files in codex_home" + ); + + Ok(()) +} + +fn codex_home_entries(codex_home: &Path) -> Result> { + Ok(std::fs::read_dir(codex_home)? + .filter_map(|entry| { + let entry = entry.ok()?; + Some(entry.file_name().to_string_lossy().into_owned()) + }) + .collect()) +} + +struct InMemoryThreadStoreId { + store_id: String, +} + +impl Drop for InMemoryThreadStoreId { + fn drop(&mut self) { + InMemoryThreadStore::remove_id(&self.store_id); + } +} + +fn create_config_toml_with_thread_store( + codex_home: &Path, + server_uri: &str, + store_id: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +experimental_thread_store = {{ type = "in_memory", id = "{store_id}" }} + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 + +[features] +plugins = false +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/request_permissions.rs b/codex-rs/app-server/tests/suite/v2/request_permissions.rs index 4f7c398f98..7acdc1b12f 100644 --- a/codex-rs/app-server/tests/suite/v2/request_permissions.rs +++ b/codex-rs/app-server/tests/suite/v2/request_permissions.rs @@ -78,12 +78,32 @@ async fn request_permissions_round_trip() -> Result<()> { assert_eq!(params.item_id, "call1"); assert!(params.cwd.as_path().is_absolute()); assert_eq!(params.reason, Some("Select a workspace root".to_string())); - let requested_writes = params + let requested_file_system = params .permissions .file_system - .and_then(|file_system| file_system.write) + .expect("request should include file system permissions"); + let requested_writes = requested_file_system + .write + .clone() .expect("request should include write permissions"); assert_eq!(requested_writes.len(), 2); + assert_eq!( + requested_file_system.entries, + Some(vec![ + codex_app_server_protocol::FileSystemSandboxEntry { + path: codex_app_server_protocol::FileSystemPath::Path { + path: requested_writes[0].clone(), + }, + access: codex_app_server_protocol::FileSystemAccessMode::Write, + }, + codex_app_server_protocol::FileSystemSandboxEntry { + path: codex_app_server_protocol::FileSystemPath::Path { + path: requested_writes[1].clone(), + }, + access: codex_app_server_protocol::FileSystemAccessMode::Write, + }, + ]) + ); let resolved_request_id = request_id.clone(); mcp.send_response( @@ -99,6 +119,7 @@ async fn request_permissions_round_trip() -> Result<()> { }), }, scope: PermissionGrantScope::Turn, + strict_auto_review: None, })?, ) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs index 31973ebda6..7c1619e38a 100644 --- a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs +++ b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs @@ -1,12 +1,16 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::to_response; +use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::ErrorNotification; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::ModelRerouteReason; use codex_app_server_protocol::ModelReroutedNotification; +use codex_app_server_protocol::ModelVerification; +use codex_app_server_protocol::ModelVerificationNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadStartParams; @@ -19,10 +23,14 @@ use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; +use wiremock::ResponseTemplate; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const REQUESTED_MODEL: &str = "gpt-5.4"; const SERVER_MODEL: &str = "gpt-5.3-codex"; +const TRUSTED_ACCESS_FOR_CYBER_VERIFICATION: &str = "trusted_access_for_cyber"; +const CYBER_POLICY_MESSAGE: &str = + "This request has been flagged for potentially high-risk cyber activity."; #[tokio::test] async fn openai_model_header_mismatch_emits_model_rerouted_notification_v2() -> Result<()> { @@ -88,6 +96,75 @@ async fn openai_model_header_mismatch_emits_model_rerouted_notification_v2() -> Ok(()) } +#[tokio::test] +async fn cyber_policy_response_emits_typed_error_notification_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response = ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "error": { + "message": CYBER_POLICY_MESSAGE, + "type": "invalid_request", + "param": null, + "code": "cyber_policy" + } + })); + let _response_mock = responses::mount_response_once(&server, response).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some(REQUESTED_MODEL.to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "trigger cyber policy error".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let turn_start: TurnStartResponse = to_response(turn_resp)?; + + let error = collect_cyber_policy_error_and_validate_no_reroute(&mut mcp).await?; + assert_eq!( + error, + ErrorNotification { + error: codex_app_server_protocol::TurnError { + message: CYBER_POLICY_MESSAGE.to_string(), + codex_error_info: Some(CodexErrorInfo::CyberPolicy), + additional_details: None, + }, + will_retry: false, + thread_id: thread.id, + turn_id: turn_start.turn.id, + } + ); + + Ok(()) +} + #[tokio::test] async fn response_model_field_mismatch_emits_model_rerouted_notification_v2_when_header_matches_requested() -> Result<()> { @@ -161,6 +238,73 @@ async fn response_model_field_mismatch_emits_model_rerouted_notification_v2_when Ok(()) } +#[tokio::test] +async fn model_verification_emits_typed_notification_and_warning_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_model_verification_metadata( + "resp-1", + vec![TRUSTED_ACCESS_FOR_CYBER_VERIFICATION], + ), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response = responses::sse_response(body); + let _response_mock = responses::mount_response_once(&server, response).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some(REQUESTED_MODEL.to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "trigger model verification".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let turn_start: TurnStartResponse = to_response(turn_resp)?; + + let verification = + collect_model_verification_notifications_and_validate_no_warning_item(&mut mcp).await?; + assert_eq!( + verification, + ModelVerificationNotification { + thread_id: thread.id, + turn_id: turn_start.turn.id, + verifications: vec![ModelVerification::TrustedAccessForCyber], + } + ); + + Ok(()) +} + async fn collect_turn_notifications_and_validate_no_warning_item( mcp: &mut McpProcess, ) -> Result { @@ -203,6 +347,90 @@ async fn collect_turn_notifications_and_validate_no_warning_item( } } +async fn collect_model_verification_notifications_and_validate_no_warning_item( + mcp: &mut McpProcess, +) -> Result { + let mut verification = None; + + loop { + let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + match notification.method.as_str() { + "model/verification" => { + let params = notification.params.ok_or_else(|| { + anyhow::anyhow!("model/verification notifications must include params") + })?; + let payload: ModelVerificationNotification = serde_json::from_value(params)?; + verification = Some(payload); + } + "warning" => { + anyhow::bail!("verification-only response must not emit warning"); + } + "model/rerouted" => { + anyhow::bail!("verification-only response must not emit model/rerouted"); + } + "item/started" => { + let params = notification.params.ok_or_else(|| { + anyhow::anyhow!("item/started notifications must include params") + })?; + let payload: ItemStartedNotification = serde_json::from_value(params)?; + assert!(!is_warning_user_message_item(&payload.item)); + } + "item/completed" => { + let params = notification.params.ok_or_else(|| { + anyhow::anyhow!("item/completed notifications must include params") + })?; + let payload: ItemCompletedNotification = serde_json::from_value(params)?; + assert!(!is_warning_user_message_item(&payload.item)); + } + "turn/completed" => { + let verification = verification.ok_or_else(|| { + anyhow::anyhow!( + "expected model/verification notification before turn/completed" + ) + })?; + return Ok(verification); + } + _ => {} + } + } +} + +async fn collect_cyber_policy_error_and_validate_no_reroute( + mcp: &mut McpProcess, +) -> Result { + let mut error = None; + + loop { + let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + match notification.method.as_str() { + "error" => { + let params = notification + .params + .ok_or_else(|| anyhow::anyhow!("error notifications must include params"))?; + let payload: ErrorNotification = serde_json::from_value(params)?; + if payload.error.codex_error_info == Some(CodexErrorInfo::CyberPolicy) { + error = Some(payload); + } + } + "model/rerouted" => { + anyhow::bail!("cyber policy response must not emit model/rerouted"); + } + "turn/completed" => { + return error.ok_or_else(|| { + anyhow::anyhow!("expected cyber policy error before turn/completed") + }); + } + _ => {} + } + } +} + fn warning_text_from_item(item: &ThreadItem) -> Option<&str> { let ThreadItem::UserMessage { content, .. } = item else { return None; diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index 8675b3a429..e9c6e3bc00 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -2,8 +2,10 @@ use std::time::Duration; use anyhow::Context; use anyhow::Result; +use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SkillsChangedNotification; @@ -11,12 +13,19 @@ use codex_app_server_protocol::SkillsListExtraRootsForCwd; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadStartParams; +use codex_config::types::AuthCredentialsStoreMode; use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; -const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); const WATCHER_TIMEOUT: Duration = Duration::from_secs(20); fn write_skill(root: &TempDir, name: &str) -> Result<()> { @@ -27,6 +36,63 @@ fn write_skill(root: &TempDir, name: &str) -> Result<()> { Ok(()) } +fn write_plugins_enabled_config_with_base_url( + codex_home: &std::path::Path, + base_url: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#"chatgpt_base_url = "{base_url}" + +[features] +plugins = true +"#, + ), + ) +} + +fn write_plugin_with_skill( + repo_root: &std::path::Path, + plugin_name: &str, + skill_name: &str, +) -> Result<()> { + std::fs::create_dir_all(repo_root.join(".git"))?; + std::fs::create_dir_all(repo_root.join(".agents/plugins"))?; + std::fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + format!( + r#"{{ + "name": "local-marketplace", + "plugins": [ + {{ + "name": "{plugin_name}", + "source": {{ + "source": "local", + "path": "./{plugin_name}" + }} + }} + ] +}}"# + ), + )?; + + let plugin_root = repo_root.join(plugin_name); + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + format!(r#"{{"name":"{plugin_name}"}}"#), + )?; + + let skill_dir = plugin_root.join("skills").join(skill_name); + std::fs::create_dir_all(&skill_dir)?; + std::fs::write( + skill_dir.join("SKILL.md"), + format!("---\nname: {skill_name}\ndescription: {skill_name} description\n---\n\n# Body\n"), + )?; + Ok(()) +} + #[tokio::test] async fn skills_list_includes_skills_from_per_cwd_extra_user_roots() -> Result<()> { let codex_home = TempDir::new()?; @@ -65,6 +131,71 @@ async fn skills_list_includes_skills_from_per_cwd_extra_user_roots() -> Result<( Ok(()) } +#[tokio::test] +async fn skills_list_excludes_plugin_skills_when_workspace_codex_plugins_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let server = MockServer::start().await; + write_skill(&codex_home, "home-skill")?; + write_plugin_with_skill(repo_root.path(), "demo-plugin", "plugin-skill")?; + write_plugins_enabled_config_with_base_url( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .plan_type("team"), + AuthCredentialsStoreMode::File, + )?; + Mock::given(method("GET")) + .and(path("/backend-api/accounts/account-123/settings")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200).set_body_string(r#"{"beta_settings":{"plugins":false}}"#), + ) + .mount(&server) + .await; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_skills_list_request(SkillsListParams { + cwds: vec![repo_root.path().to_path_buf()], + force_reload: true, + per_cwd_extra_user_roots: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let SkillsListResponse { data } = to_response(response)?; + assert_eq!(data.len(), 1); + assert!( + data[0] + .skills + .iter() + .any(|skill| skill.name == "home-skill"), + "non-plugin skills should remain available" + ); + assert!( + data[0] + .skills + .iter() + .all(|skill| skill.name != "demo-plugin:plugin-skill"), + "plugin skills should be hidden when workspace Codex plugins are disabled" + ); + Ok(()) +} + #[tokio::test] async fn skills_list_skips_cwd_roots_when_environment_disabled() -> Result<()> { let codex_home = TempDir::new()?; @@ -315,6 +446,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<( approval_policy: None, approvals_reviewer: None, sandbox: None, + permission_profile: None, config: None, service_name: None, base_instructions: None, @@ -323,6 +455,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<( ephemeral: None, session_start_source: None, dynamic_tools: None, + environments: None, mock_experimental_field: None, experimental_raw_events: false, persist_extended_history: false, diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 89980c7c62..fd773f2e30 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -32,6 +32,7 @@ use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; use std::path::Path; +use std::path::PathBuf; use tempfile::TempDir; use tokio::time::timeout; use wiremock::Mock; @@ -41,7 +42,7 @@ use wiremock::matchers::method; use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; -use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; use super::analytics::wait_for_analytics_payload; @@ -49,6 +50,7 @@ use super::analytics::wait_for_analytics_payload; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const INTERNAL_ERROR_CODE: i64 = -32603; #[tokio::test] async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { @@ -181,9 +183,98 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { Some(&Value::Null), "thread/started must serialize `name: null` when unset" ); + assert_eq!( + started_thread_json.get("turns"), + Some(&json!([])), + "thread/started must not emit copied fork turns" + ); let started: ThreadStartedNotification = serde_json::from_value(notif.params.expect("params must be present"))?; - assert_eq!(started.thread, thread); + let mut expected_started_thread = thread; + expected_started_thread.turns.clear(); + assert_eq!(started.thread, expected_started_thread); + + Ok(()) +} + +#[tokio::test] +async fn thread_fork_can_load_source_by_path() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let preview = "Saved user message"; + let conversation_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + preview, + Some("mock_provider"), + /*git_info*/ None, + )?; + let original_path = codex_home + .path() + .join("sessions") + .join("2025") + .join("01") + .join("05") + .join(format!( + "rollout-2025-01-05T12-00-00-{conversation_id}.jsonl" + )); + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: "not-a-valid-thread-id".to_string(), + path: Some(original_path), + ..Default::default() + }) + .await?; + let fork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), + ) + .await??; + let ThreadForkResponse { thread, .. } = to_response::(fork_resp)?; + + assert_ne!(thread.id, conversation_id); + assert_eq!(thread.forked_from_id, Some(conversation_id)); + assert_eq!(thread.preview, preview); + assert_eq!(thread.model_provider, "mock_provider"); + assert_eq!(thread.turns.len(), 1, "expected copied fork history"); + + Ok(()) +} + +#[tokio::test] +async fn thread_fork_by_path_uses_remote_thread_store_error() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml_with_remote_thread_store(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: "not-a-valid-thread-id".to_string(), + path: Some(PathBuf::from("sessions/2025/01/05/rollout.jsonl")), + ..Default::default() + }) + .await?; + let fork_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(fork_id)), + ) + .await??; + + assert_eq!(fork_err.error.code, INTERNAL_ERROR_CODE); + assert_eq!( + fork_err.error.message, + "failed to read thread: thread-store internal error: remote thread store does not support read_thread_by_rollout_path" + ); Ok(()) } @@ -241,18 +332,61 @@ async fn thread_fork_emits_restored_token_usage_before_next_turn() -> Result<()> Ok(()) } +#[tokio::test] +async fn thread_fork_can_exclude_turns_and_skip_restored_token_usage() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let conversation_id = create_fake_rollout_with_token_usage( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: conversation_id.clone(), + exclude_turns: true, + ..Default::default() + }) + .await?; + let fork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), + ) + .await??; + let ThreadForkResponse { thread, .. } = to_response::(fork_resp)?; + + assert_eq!(thread.forked_from_id, Some(conversation_id)); + assert_eq!(thread.preview, "Saved user message"); + assert!(thread.turns.is_empty()); + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/tokenUsage/updated"), + ) + .await; + assert!( + note.is_err(), + "excludeTurns=true should not replay token usage" + ); + + Ok(()) +} + #[tokio::test] async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; - create_config_toml_with_chatgpt_base_url( - codex_home.path(), - &server.uri(), - &server.uri(), - /*general_analytics_enabled*/ true, - )?; - enable_analytics_capture(&server, codex_home.path()).await?; + create_config_toml_with_chatgpt_base_url(codex_home.path(), &server.uri(), &server.uri())?; + mount_analytics_capture(&server, codex_home.path()).await?; let conversation_id = create_fake_rollout( codex_home.path(), @@ -357,7 +491,6 @@ async fn thread_fork_surfaces_cloud_requirements_load_errors() -> Result<()> { codex_home.path(), &model_server.uri(), &chatgpt_base_url, - /*general_analytics_enabled*/ false, )?; write_chatgpt_auth( codex_home.path(), @@ -534,9 +667,16 @@ async fn thread_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<() Some(true), "thread/started should serialize `ephemeral: true` for ephemeral forks" ); + assert_eq!( + started_thread_json.get("turns"), + Some(&json!([])), + "thread/started must not emit copied ephemeral fork turns" + ); let started: ThreadStartedNotification = serde_json::from_value(notif.params.expect("params must be present"))?; - assert_eq!(started.thread, thread); + let mut expected_started_thread = thread; + expected_started_thread.turns.clear(); + assert_eq!(started.thread, expected_started_thread); let list_id = mcp .send_thread_list_request(ThreadListParams { @@ -548,6 +688,7 @@ async fn thread_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<() source_kinds: None, archived: None, cwd: None, + use_state_db_only: false, search_term: None, }) .await?; @@ -615,17 +756,38 @@ stream_max_retries = 0 ) } +fn create_config_toml_with_remote_thread_store( + codex_home: &Path, + server_uri: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +experimental_thread_store_endpoint = "http://127.0.0.1:1" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} + fn create_config_toml_with_chatgpt_base_url( codex_home: &Path, server_uri: &str, chatgpt_base_url: &str, - general_analytics_enabled: bool, ) -> std::io::Result<()> { - let general_analytics_toml = if general_analytics_enabled { - "\ngeneral_analytics = true".to_string() - } else { - "\ngeneral_analytics = false".to_string() - }; let config_toml = codex_home.join("config.toml"); std::fs::write( config_toml, @@ -638,9 +800,6 @@ chatgpt_base_url = "{chatgpt_base_url}" model_provider = "mock_provider" -[features] -{general_analytics_toml} - [model_providers.mock_provider] name = "Mock provider for test" base_url = "{server_uri}/v1" diff --git a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs index 56fd188c4b..5a45e81e1d 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs @@ -59,7 +59,6 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history() -> Resu content: vec![ContentItem::OutputText { text: injected_text.to_string(), }], - end_turn: None, phase: None, }; @@ -195,7 +194,6 @@ async fn thread_inject_items_adds_raw_response_items_after_a_turn() -> Result<() content: vec![ContentItem::OutputText { text: "Injected after first turn".to_string(), }], - end_turn: None, phase: None, }; let injected_value = serde_json::to_value(&injected_item)?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 247bbb4126..615692d70d 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -15,6 +15,7 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::SortDirection; +use codex_app_server_protocol::ThreadListCwdFilter; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadSortKey; use codex_app_server_protocol::ThreadSourceKind; @@ -90,6 +91,7 @@ async fn list_threads_with_sort( source_kinds, archived, cwd: None, + use_state_db_only: false, search_term: None, }) .await?; @@ -468,15 +470,23 @@ async fn thread_list_respects_provider_filter() -> Result<()> { } #[tokio::test] -async fn thread_list_respects_cwd_filter() -> Result<()> { +async fn thread_list_respects_cwd_filters() -> Result<()> { let codex_home = TempDir::new()?; create_minimal_config(codex_home.path())?; - let filtered_id = create_fake_rollout( + let first_filtered_id = create_fake_rollout( codex_home.path(), "2025-01-02T10-00-00", "2025-01-02T10:00:00Z", - "filtered", + "first filtered", + Some("mock_provider"), + /*git_info*/ None, + )?; + let second_filtered_id = create_fake_rollout( + codex_home.path(), + "2025-01-02T12-00-00", + "2025-01-02T12:00:00Z", + "second filtered", Some("mock_provider"), /*git_info*/ None, )?; @@ -489,11 +499,22 @@ async fn thread_list_respects_cwd_filter() -> Result<()> { /*git_info*/ None, )?; - let target_cwd = codex_home.path().join("target-cwd"); - fs::create_dir_all(&target_cwd)?; + let first_target_cwd = codex_home.path().join("first-target-cwd"); + let second_target_cwd = codex_home.path().join("second-target-cwd"); + fs::create_dir_all(&first_target_cwd)?; + fs::create_dir_all(&second_target_cwd)?; set_rollout_cwd( - rollout_path(codex_home.path(), "2025-01-02T10-00-00", &filtered_id).as_path(), - &target_cwd, + rollout_path(codex_home.path(), "2025-01-02T10-00-00", &first_filtered_id).as_path(), + &first_target_cwd, + )?; + set_rollout_cwd( + rollout_path( + codex_home.path(), + "2025-01-02T12-00-00", + &second_filtered_id, + ) + .as_path(), + &second_target_cwd, )?; let mut mcp = init_mcp(codex_home.path()).await?; @@ -506,7 +527,11 @@ async fn thread_list_respects_cwd_filter() -> Result<()> { model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, - cwd: Some(target_cwd.to_string_lossy().into_owned()), + cwd: Some(ThreadListCwdFilter::Many(vec![ + first_target_cwd.to_string_lossy().into_owned(), + second_target_cwd.to_string_lossy().into_owned(), + ])), + use_state_db_only: false, search_term: None, }) .await?; @@ -520,10 +545,14 @@ async fn thread_list_respects_cwd_filter() -> Result<()> { } = to_response::(resp)?; assert_eq!(next_cursor, None); - assert_eq!(data.len(), 1); - assert_eq!(data[0].id, filtered_id); - assert_ne!(data[0].id, unfiltered_id); - assert_eq!(data[0].cwd.as_path(), target_cwd.as_path()); + let filtered_ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!( + filtered_ids, + vec![second_filtered_id.as_str(), first_filtered_id.as_str()] + ); + assert!(!filtered_ids.contains(&unfiltered_id.as_str())); + assert_eq!(data[0].cwd.as_path(), second_target_cwd.as_path()); + assert_eq!(data[1].cwd.as_path(), first_target_cwd.as_path()); Ok(()) } @@ -592,6 +621,7 @@ sqlite = true codex_core::SortDirection::Desc, &[], /*model_providers*/ None, + /*cwd_filters*/ None, "mock_provider", /*search_term*/ None, ) @@ -609,6 +639,7 @@ sqlite = true source_kinds: None, archived: None, cwd: None, + use_state_db_only: false, search_term: Some("needle".to_string()), }) .await?; @@ -628,6 +659,129 @@ sqlite = true Ok(()) } +#[tokio::test] +async fn thread_list_state_db_only_returns_sqlite_without_jsonl_repair() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#" +model = "mock-model" +approval_policy = "never" +suppress_unstable_features_warning = true + +[features] +sqlite = true +"#, + )?; + + let thread_id = create_fake_rollout( + codex_home.path(), + "2025-01-02T10-00-00", + "2025-01-02T10:00:00Z", + "state db only should not see this before repair", + Some("mock_provider"), + /*git_info*/ None, + )?; + let state_db = + codex_state::StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()) + .await?; + state_db + .mark_backfill_complete(/*last_watermark*/ None) + .await?; + let mut mcp = init_mcp(codex_home.path()).await?; + + let request_id = mcp + .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + cursor: None, + limit: Some(10), + sort_key: None, + sort_direction: None, + model_providers: Some(vec!["mock_provider".to_string()]), + source_kinds: None, + archived: None, + cwd: None, + use_state_db_only: false, + search_term: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let repaired_response = to_response::(resp)?; + let ids: Vec<_> = repaired_response + .data + .iter() + .map(|thread| thread.id.as_str()) + .collect(); + assert_eq!(ids, vec![thread_id.as_str()]); + + let thread_uuid = ThreadId::from_string(&thread_id)?; + let stale_cwd = codex_home.path().join("stale-cwd"); + let mut metadata = state_db + .get_thread(thread_uuid) + .await? + .expect("thread should be repaired into sqlite"); + metadata.cwd = stale_cwd.clone(); + state_db.upsert_thread(&metadata).await?; + + let request_id = mcp + .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + cursor: None, + limit: Some(10), + sort_key: None, + sort_direction: None, + model_providers: Some(vec!["mock_provider".to_string()]), + source_kinds: None, + archived: None, + cwd: Some(ThreadListCwdFilter::One( + stale_cwd.to_string_lossy().into_owned(), + )), + use_state_db_only: true, + search_term: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let state_db_only_response = to_response::(resp)?; + let ids: Vec<_> = state_db_only_response + .data + .iter() + .map(|thread| thread.id.as_str()) + .collect(); + assert_eq!(ids, vec![thread_id.as_str()]); + + let request_id = mcp + .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + cursor: None, + limit: Some(10), + sort_key: None, + sort_direction: None, + model_providers: Some(vec!["mock_provider".to_string()]), + source_kinds: None, + archived: None, + cwd: Some(ThreadListCwdFilter::One( + stale_cwd.to_string_lossy().into_owned(), + )), + use_state_db_only: false, + search_term: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let scanned_response = to_response::(resp)?; + assert_eq!(scanned_response.data.len(), 0); + + Ok(()) +} + #[tokio::test] async fn thread_list_empty_source_kinds_defaults_to_interactive_only() -> Result<()> { let codex_home = TempDir::new()?; @@ -1307,6 +1461,7 @@ async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<() source_kinds: None, archived: None, cwd: None, + use_state_db_only: false, search_term: None, }) .await?; @@ -1348,6 +1503,7 @@ async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<() source_kinds: None, archived: None, cwd: None, + use_state_db_only: false, search_term: None, }) .await?; @@ -1585,6 +1741,7 @@ async fn thread_list_invalid_cursor_returns_error() -> Result<()> { source_kinds: None, archived: None, cwd: None, + use_state_db_only: false, search_term: None, }) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 7cee2f0990..8e0e253ac0 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -548,6 +548,7 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> { source_kinds: None, archived: None, cwd: None, + use_state_db_only: false, search_term: None, }) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 7a5f2c543d..d9f5f039de 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -2,6 +2,7 @@ use anyhow::Result; use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; +use app_test_support::create_fake_rollout; use app_test_support::create_fake_rollout_with_text_elements; use app_test_support::create_fake_rollout_with_token_usage; use app_test_support::create_final_assistant_message_sse_response; @@ -27,6 +28,9 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SessionSource; +use codex_app_server_protocol::ThreadGoalClearResponse; +use codex_app_server_protocol::ThreadGoalSetResponse; +use codex_app_server_protocol::ThreadGoalStatus; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams; use codex_app_server_protocol::ThreadMetadataUpdateParams; @@ -61,6 +65,7 @@ use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement; use codex_state::StateRuntime; +use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; @@ -79,7 +84,7 @@ use wiremock::matchers::method; use wiremock::matchers::path; use super::analytics::assert_basic_thread_initialized_event; -use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; use super::analytics::thread_initialized_event; use super::analytics::wait_for_analytics_payload; @@ -87,8 +92,13 @@ use super::analytics::wait_for_analytics_payload; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const INTERNAL_ERROR_CODE: i64 = -32603; const CODEX_5_2_INSTRUCTIONS_TEMPLATE_DEFAULT: &str = "You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals."; +fn normalized_existing_path(path: impl AsRef) -> Result { + Ok(AbsolutePathBuf::from_absolute_path(path.as_ref().canonicalize()?)?.into_path_buf()) +} + async fn wait_for_responses_request_count( server: &wiremock::MockServer, expected_count: usize, @@ -166,18 +176,67 @@ async fn thread_resume_rejects_unmaterialized_thread() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_goal_get_rejects_unmaterialized_thread() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config.replace("personality = true\n", "personality = true\ngoals = true\n"), + )?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.2-codex".to_string()), + ephemeral: Some(true), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let goal_id = mcp + .send_raw_request( + "thread/goal/get", + Some(json!({ + "threadId": thread.id, + })), + ) + .await?; + let goal_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(goal_id)), + ) + .await??; + assert!( + goal_err + .error + .message + .contains("ephemeral thread does not support goals"), + "unexpected goal/get error: {}", + goal_err.error.message + ); + + Ok(()) +} + #[tokio::test] async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; - create_config_toml_with_chatgpt_base_url( - codex_home.path(), - &server.uri(), - &server.uri(), - /*general_analytics_enabled*/ true, - )?; - enable_analytics_capture(&server, codex_home.path()).await?; + create_config_toml_with_chatgpt_base_url(codex_home.path(), &server.uri(), &server.uri())?; + mount_analytics_capture(&server, codex_home.path()).await?; let conversation_id = create_fake_rollout_with_text_elements( codex_home.path(), @@ -285,6 +344,405 @@ async fn thread_resume_returns_rollout_history() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_resume_can_skip_turns_for_metadata_only_resume() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Vec::new(), + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id.clone(), + exclude_turns: true, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; + + assert_eq!(thread.id, conversation_id); + assert!(thread.turns.is_empty()); + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_emits_active_goal_update_before_continuation() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config.replace("personality = true\n", "personality = true\ngoals = true\n"), + )?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.2-codex".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "materialize this thread".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let _turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let goal_id = mcp + .send_raw_request( + "thread/goal/set", + Some(json!({ + "threadId": thread.id, + "objective": "keep polishing", + "status": "paused", + })), + ) + .await?; + let goal_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(goal_id)), + ) + .await??; + let _goal: ThreadGoalSetResponse = to_response(goal_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/goal/updated"), + ) + .await??; + mcp.clear_message_buffer(); + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id.clone(), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let _resume: ThreadResumeResponse = to_response(resume_resp)?; + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/goal/updated"), + ) + .await??; + let notification: ServerNotification = notification.try_into()?; + let ServerNotification::ThreadGoalUpdated(notification) = notification else { + anyhow::bail!("expected thread goal update notification"); + }; + assert_eq!(notification.goal.status, ThreadGoalStatus::Active); + assert!( + !mcp.pending_notification_methods() + .iter() + .any(|method| method == "turn/started"), + "goal continuation should start only after the resume goal snapshot" + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_goal_set_preserves_budget_limited_same_objective() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config.replace("personality = true\n", "personality = true\ngoals = true\n"), + )?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.2-codex".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "materialize this thread".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let _turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let goal_id = mcp + .send_raw_request( + "thread/goal/set", + Some(json!({ + "threadId": thread.id, + "objective": "keep polishing", + "status": "budgetLimited", + "tokenBudget": 10, + })), + ) + .await?; + let goal_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(goal_id)), + ) + .await??; + let goal: ThreadGoalSetResponse = to_response(goal_resp)?; + assert_eq!(goal.goal.status, ThreadGoalStatus::BudgetLimited); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/goal/updated"), + ) + .await??; + + let replacement_id = mcp + .send_raw_request( + "thread/goal/set", + Some(json!({ + "threadId": thread.id, + "objective": "keep polishing", + })), + ) + .await?; + let replacement_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(replacement_id)), + ) + .await??; + let replacement: ThreadGoalSetResponse = to_response(replacement_resp)?; + + assert_eq!(replacement.goal.status, ThreadGoalStatus::BudgetLimited); + assert_eq!(replacement.goal.token_budget, Some(10)); + assert_eq!(replacement.goal.tokens_used, 0); + assert_eq!(replacement.goal.time_used_seconds, 0); + + Ok(()) +} + +#[tokio::test] +async fn thread_goal_clear_deletes_goal_and_notifies() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config.replace("personality = true\n", "personality = true\ngoals = true\n"), + )?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.2-codex".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "materialize this thread".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let _turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let goal_id = mcp + .send_raw_request( + "thread/goal/set", + Some(json!({ + "threadId": thread.id, + "objective": "keep polishing", + })), + ) + .await?; + let goal_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(goal_id)), + ) + .await??; + let _goal: ThreadGoalSetResponse = to_response(goal_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/goal/updated"), + ) + .await??; + + let clear_id = mcp + .send_raw_request( + "thread/goal/clear", + Some(json!({ + "threadId": thread.id, + })), + ) + .await?; + let clear_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(clear_id)), + ) + .await??; + let clear: ThreadGoalClearResponse = to_response(clear_resp)?; + assert!(clear.cleared); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/goal/cleared"), + ) + .await??; + + let get_id = mcp + .send_raw_request( + "thread/goal/get", + Some(json!({ + "threadId": thread.id, + })), + ) + .await?; + let get_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(get_id)), + ) + .await??; + let get: codex_app_server_protocol::ThreadGoalGetResponse = to_response(get_resp)?; + assert_eq!(None, get.goal); + + let clear_again_id = mcp + .send_raw_request( + "thread/goal/clear", + Some(json!({ + "threadId": thread.id, + })), + ) + .await?; + let clear_again_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(clear_again_id)), + ) + .await??; + let clear_again: ThreadGoalClearResponse = to_response(clear_again_resp)?; + assert!(!clear_again.cleared); + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_by_path_uses_remote_thread_store_error() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml_with_remote_thread_store(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: "ignored-when-path-is-present".to_string(), + path: Some(PathBuf::from("sessions/2025/01/05/rollout.jsonl")), + ..Default::default() + }) + .await?; + let resume_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(resume_id)), + ) + .await??; + + assert_eq!(resume_err.error.code, INTERNAL_ERROR_CODE); + assert_eq!( + resume_err.error.message, + "failed to read thread: thread-store internal error: remote thread store does not support read_thread_by_rollout_path" + ); + + Ok(()) +} + #[tokio::test] async fn thread_resume_emits_restored_token_usage_before_next_turn() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -338,6 +796,80 @@ async fn thread_resume_emits_restored_token_usage_before_next_turn() -> Result<( Ok(()) } +#[tokio::test] +async fn thread_resume_skips_restored_token_usage_when_turns_are_excluded() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let conversation_id = create_fake_rollout_with_token_usage( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let first_resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id.clone(), + ..Default::default() + }) + .await?; + let first_resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_resume_id)), + ) + .await??; + let ThreadResumeResponse { thread, .. } = + to_response::(first_resume_resp)?; + let expected_turn_id = thread.turns[0].id.clone(); + + let first_note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/tokenUsage/updated"), + ) + .await??; + let parsed: ServerNotification = first_note.try_into()?; + let ServerNotification::ThreadTokenUsageUpdated(notification) = parsed else { + panic!("expected thread/tokenUsage/updated notification"); + }; + assert_eq!(notification.turn_id, expected_turn_id); + + let second_resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id, + exclude_turns: true, + ..Default::default() + }) + .await?; + let second_resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_resume_id)), + ) + .await??; + let ThreadResumeResponse { + thread: resumed_again, + .. + } = to_response::(second_resume_resp)?; + assert!(resumed_again.turns.is_empty()); + + let second_note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/tokenUsage/updated"), + ) + .await; + assert!( + second_note.is_err(), + "excludeTurns=true should not replay token usage" + ); + + Ok(()) +} + #[tokio::test] async fn thread_resume_token_usage_replay_ignores_stale_interrupted_tail_turn() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -865,6 +1397,22 @@ async fn thread_resume_without_overrides_does_not_change_updated_at_or_mtime() - let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread_id.clone(), + include_turns: false, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { + thread: before_resume, + .. + } = to_response::(read_resp)?; + let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { thread_id: thread_id.clone(), @@ -878,7 +1426,7 @@ async fn thread_resume_without_overrides_does_not_change_updated_at_or_mtime() - .await??; let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; - assert_eq!(thread.updated_at, rollout.expected_updated_at); + assert_eq!(thread.updated_at, before_resume.updated_at); assert_eq!(thread.status, ThreadStatus::Idle); let after_modified = std::fs::metadata(&rollout.rollout_file_path)?.modified()?; @@ -1095,7 +1643,6 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> { content: vec![ContentItem::InputText { text: "history override".to_string(), }], - end_turn: None, phase: None, }]), ..Default::default() @@ -1339,6 +1886,84 @@ async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> R Ok(()) } +#[tokio::test] +async fn thread_resume_can_skip_turns_when_thread_is_running() -> Result<()> { + let server = responses::start_mock_server().await; + let _response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut primary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; + + let start_id = primary + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.4".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "seed history".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let mut secondary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??; + + let resume_id = secondary + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id.clone(), + exclude_turns: true, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + secondary.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + thread: resumed, .. + } = to_response::(resume_resp)?; + + assert_eq!(resumed.id, thread.id); + assert_eq!(resumed.status, ThreadStatus::Idle); + assert!(resumed.turns.is_empty()); + + Ok(()) +} + #[tokio::test] async fn thread_resume_replays_pending_command_execution_request_approval() -> Result<()> { let responses = vec![ @@ -1651,13 +2276,11 @@ async fn thread_resume_with_overrides_defers_updated_at_until_turn_start() -> Re mut mcp, thread_id, rollout_file_path, + updated_at, } = start_materialized_thread_and_restart(codex_home.path(), "materialize").await?; let expected_updated_at_rfc3339 = "2025-01-07T00:00:00Z"; set_rollout_mtime(rollout_file_path.as_path(), expected_updated_at_rfc3339)?; let before_modified = std::fs::metadata(&rollout_file_path)?.modified()?; - let expected_updated_at = chrono::DateTime::parse_from_rfc3339(expected_updated_at_rfc3339)? - .with_timezone(&Utc) - .timestamp(); let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { @@ -1676,7 +2299,7 @@ async fn thread_resume_with_overrides_defers_updated_at_until_turn_start() -> Re .. } = to_response::(resume_resp)?; - assert_eq!(resumed_thread.updated_at, expected_updated_at); + assert_eq!(resumed_thread.updated_at, updated_at); assert_eq!(resumed_thread.status, ThreadStatus::Idle); let after_resume_modified = std::fs::metadata(&rollout_file_path)?.modified()?; @@ -1774,7 +2397,6 @@ async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> { codex_home.path(), &model_server.uri(), &chatgpt_base_url, - /*general_analytics_enabled*/ false, )?; write_chatgpt_auth( codex_home.path(), @@ -1901,7 +2523,58 @@ async fn thread_resume_prefers_path_over_thread_id() -> Result<()> { thread: resumed, .. } = to_response::(resume_resp)?; assert_eq!(resumed.id, thread.id); - assert_eq!(resumed.path, thread.path); + let resumed_path = resumed.path.as_ref().expect("resumed thread path"); + let original_path = thread.path.as_ref().expect("original thread path"); + assert_eq!( + normalized_existing_path(resumed_path)?, + normalized_existing_path(original_path)? + ); + assert_eq!(resumed.status, ThreadStatus::Idle); + + Ok(()) +} + +#[tokio::test] +async fn thread_resume_can_load_source_by_external_path() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + let external_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let thread_id = create_fake_rollout( + external_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "external path history", + Some("mock_provider"), + /*git_info*/ None, + )?; + let thread_path = rollout_path(external_home.path(), "2025-01-05T12-00-00", &thread_id); + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: "not-a-valid-thread-id".to_string(), + path: Some(thread_path.clone()), + ..Default::default() + }) + .await?; + + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + thread: resumed, .. + } = to_response::(resume_resp)?; + assert_eq!(resumed.id, thread_id); + let resumed_path = resumed.path.as_ref().expect("resumed thread path"); + assert_eq!( + normalized_existing_path(resumed_path)?, + normalized_existing_path(&thread_path)? + ); + assert_eq!(resumed.preview, "external path history"); assert_eq!(resumed.status, ThreadStatus::Idle); Ok(()) @@ -1924,7 +2597,6 @@ async fn thread_resume_supports_history_and_overrides() -> Result<()> { content: vec![ContentItem::InputText { text: history_text.to_string(), }], - end_turn: None, phase: None, }]; @@ -1960,6 +2632,7 @@ struct RestartedThreadFixture { mcp: McpProcess, thread_id: String, rollout_file_path: PathBuf, + updated_at: i64, } async fn start_materialized_thread_and_restart( @@ -2003,10 +2676,24 @@ async fn start_materialized_thread_and_restart( ) .await??; + let read_id = first_mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread.id.clone(), + include_turns: false, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + first_mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { thread, .. } = to_response::(read_resp)?; + let thread_id = thread.id; let rollout_file_path = thread .path .ok_or_else(|| anyhow::anyhow!("thread path missing from thread/start response"))?; + let updated_at = thread.updated_at; drop(first_mcp); @@ -2017,6 +2704,7 @@ async fn start_materialized_thread_and_restart( mcp: second_mcp, thread_id, rollout_file_path: rollout_file_path.to_path_buf(), + updated_at, }) } @@ -2153,7 +2841,36 @@ model_provider = "mock_provider" [features] personality = true -general_analytics = true + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} + +fn create_config_toml_with_remote_thread_store( + codex_home: &std::path::Path, + server_uri: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "gpt-5.3-codex" +approval_policy = "never" +sandbox_mode = "read-only" +experimental_thread_store_endpoint = "http://127.0.0.1:1" + +model_provider = "mock_provider" + +[features] +personality = true [model_providers.mock_provider] name = "Mock provider for test" @@ -2170,13 +2887,7 @@ fn create_config_toml_with_chatgpt_base_url( codex_home: &std::path::Path, server_uri: &str, chatgpt_base_url: &str, - general_analytics_enabled: bool, ) -> std::io::Result<()> { - let general_analytics_toml = if general_analytics_enabled { - "\ngeneral_analytics = true".to_string() - } else { - "\ngeneral_analytics = false".to_string() - }; let config_toml = codex_home.join("config.toml"); std::fs::write( config_toml, @@ -2191,7 +2902,6 @@ model_provider = "mock_provider" [features] personality = true -{general_analytics_toml} [model_providers.mock_provider] name = "Mock provider for test" @@ -2252,7 +2962,6 @@ struct RolloutFixture { conversation_id: String, rollout_file_path: PathBuf, before_modified: std::time::SystemTime, - expected_updated_at: i64, } fn setup_rollout_fixture(codex_home: &Path, server_uri: &str) -> Result { @@ -2274,14 +2983,9 @@ fn setup_rollout_fixture(codex_home: &Path, server_uri: &str) -> Result Result<()> { @@ -166,6 +167,39 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + environments: Some(vec![TurnEnvironmentParams { + environment_id: "missing".to_string(), + cwd: codex_home.path().to_path_buf().try_into()?, + }]), + ..Default::default() + }) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.id, RequestId::Integer(request_id)); + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!(error.error.message, "unknown turn environment id `missing`"); + + Ok(()) +} + #[tokio::test] async fn thread_start_response_includes_loaded_instruction_sources() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -230,12 +264,7 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; - create_config_toml_with_chatgpt_base_url( - codex_home.path(), - &server.uri(), - &server.uri(), - /*general_analytics_enabled*/ true, - )?; + create_config_toml_with_chatgpt_base_url(codex_home.path(), &server.uri(), &server.uri())?; mount_analytics_capture(&server, codex_home.path()).await?; let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; @@ -258,54 +287,6 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { Ok(()) } -#[tokio::test] -async fn thread_start_does_not_track_thread_initialized_analytics_without_feature() -> Result<()> { - let server = create_mock_responses_server_repeating_assistant("Done").await; - - let codex_home = TempDir::new()?; - create_config_toml_with_chatgpt_base_url( - codex_home.path(), - &server.uri(), - &server.uri(), - /*general_analytics_enabled*/ false, - )?; - mount_analytics_capture(&server, codex_home.path()).await?; - - let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let req_id = mcp - .send_thread_start_request(ThreadStartParams::default()) - .await?; - let resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(req_id)), - ) - .await??; - let _ = to_response::(resp)?; - - assert_no_thread_initialized_analytics(&server, Duration::from_millis(250)).await?; - Ok(()) -} - -async fn assert_no_thread_initialized_analytics( - server: &MockServer, - wait_duration: Duration, -) -> Result<()> { - tokio::time::sleep(wait_duration).await; - let requests = server.received_requests().await.unwrap_or_default(); - for request in requests.iter().filter(|request| { - request.method == "POST" && request.url.path() == "/codex/analytics-events/events" - }) { - let payload: Value = serde_json::from_slice(&request.body)?; - assert!( - thread_initialized_event(&payload).is_err(), - "thread analytics should be gated off when general_analytics is disabled; payload={payload}" - ); - } - Ok(()) -} - #[tokio::test] async fn thread_start_respects_project_config_from_cwd() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -608,7 +589,6 @@ async fn thread_start_surfaces_cloud_requirements_load_errors() -> Result<()> { codex_home.path(), &model_server.uri(), &chatgpt_base_url, - /*general_analytics_enabled*/ false, )?; write_chatgpt_auth( codex_home.path(), @@ -802,6 +782,44 @@ async fn thread_start_with_read_only_sandbox_does_not_persist_project_trust() -> Ok(()) } +#[tokio::test] +async fn thread_start_preserves_untrusted_project_trust() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let workspace = TempDir::new()?; + let config_path = codex_home.path().join("config.toml"); + let workspace_key = workspace.path().display().to_string(); + let mut config_toml = + std::fs::read_to_string(&config_path)?.parse::()?; + config_toml["projects"][workspace_key.as_str()]["trust_level"] = toml_edit::value("untrusted"); + std::fs::write(&config_path, config_toml.to_string())?; + let config_before = std::fs::read_to_string(&config_path)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + sandbox: Some(SandboxMode::WorkspaceWrite), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let config_after = std::fs::read_to_string(&config_path)?; + assert_eq!(config_after, config_before); + + Ok(()) +} + #[tokio::test] async fn thread_start_skips_trust_write_when_project_is_already_trusted() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -893,13 +911,7 @@ fn create_config_toml_with_chatgpt_base_url( codex_home: &Path, server_uri: &str, chatgpt_base_url: &str, - general_analytics_enabled: bool, ) -> std::io::Result<()> { - let general_analytics_toml = if general_analytics_enabled { - "\ngeneral_analytics = true".to_string() - } else { - "\ngeneral_analytics = false".to_string() - }; let config_toml = codex_home.join("config.toml"); std::fs::write( config_toml, @@ -912,9 +924,6 @@ chatgpt_base_url = "{chatgpt_base_url}" model_provider = "mock_provider" -[features] -{general_analytics_toml} - [model_providers.mock_provider] name = "Mock provider for test" base_url = "{server_uri}/v1" diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index f8eaf799da..aedc54e016 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -2,10 +2,12 @@ use anyhow::Result; use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -24,6 +26,7 @@ use tempfile::TempDir; use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const INVALID_REQUEST_ERROR_CODE: i64 = -32600; #[tokio::test] async fn turn_interrupt_aborts_running_turn() -> Result<()> { @@ -125,6 +128,82 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_interrupt_rejects_completed_turn() -> Result<()> { + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + + let server = create_mock_responses_server_sequence_unchecked(vec![ + create_final_assistant_message_sse_response("done")?, + ]) + .await; + create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "say done".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.thread_id, thread.id); + assert_eq!(completed.turn.id, turn.id); + assert_eq!(completed.turn.status, TurnStatus::Completed); + + let interrupt_id = mcp + .send_turn_interrupt_request(TurnInterruptParams { + thread_id: thread.id, + turn_id: turn.id, + }) + .await?; + + let interrupt_err: JSONRPCError = timeout( + std::time::Duration::from_millis(500), + mcp.read_stream_until_error_message(RequestId::Integer(interrupt_id)), + ) + .await??; + assert_eq!(interrupt_err.error.code, INVALID_REQUEST_ERROR_CODE); + + Ok(()) +} + #[tokio::test] async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<()> { #[cfg(target_os = "windows")] diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 65f8442b0d..f81ec5f168 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -5,6 +5,7 @@ use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; use app_test_support::create_fake_rollout; use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; @@ -26,6 +27,9 @@ use codex_app_server_protocol::FileChangeApprovalDecision; use codex_app_server_protocol::FileChangeOutputDeltaNotification; use codex_app_server_protocol::FileChangePatchUpdatedNotification; use codex_app_server_protocol::FileChangeRequestApprovalResponse; +use codex_app_server_protocol::FileSystemAccessMode; +use codex_app_server_protocol::FileSystemPath; +use codex_app_server_protocol::FileSystemSandboxEntry; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCError; @@ -34,6 +38,9 @@ use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::PatchChangeKind; +use codex_app_server_protocol::PermissionProfile; +use codex_app_server_protocol::PermissionProfileFileSystemPermissions; +use codex_app_server_protocol::PermissionProfileNetworkPermissions; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestResolvedNotification; @@ -42,6 +49,7 @@ use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnEnvironmentParams; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStartedNotification; @@ -59,6 +67,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; +use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; @@ -69,7 +78,6 @@ use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; -use super::analytics::enable_analytics_capture; use super::analytics::mount_analytics_capture; use super::analytics::wait_for_analytics_event; @@ -79,6 +87,7 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const TEST_ORIGINATOR: &str = "codex_vscode"; const LOCAL_PRAGMATIC_TEMPLATE: &str = "You are a deeply pragmatic, effective software engineer."; +const INVALID_REQUEST_ERROR_CODE: i64 = -32600; fn body_contains(req: &wiremock::Request, text: &str) -> bool { String::from_utf8(req.body.clone()) @@ -324,7 +333,7 @@ async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills( assert_eq!(warning.thread_id.as_deref(), Some(thread.id.as_str())); assert_eq!( warning.message, - "Some enabled skills were not included in the model-visible skills list for this session. Mention a skill by name or path if you need it." + "Warning: Exceeded skills context budget of 2%. All skill descriptions were removed and 7 additional skills were not included in the model-visible skills list." ); timeout( @@ -453,7 +462,7 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { &server.uri(), &server.uri(), )?; - enable_analytics_capture(&server, codex_home.path()).await?; + mount_analytics_capture(&server, codex_home.path()).await?; let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -528,77 +537,6 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { Ok(()) } -#[tokio::test] -async fn turn_start_does_not_track_turn_event_analytics_without_feature() -> Result<()> { - let responses = vec![create_final_assistant_message_sse_response("Done")?]; - let server = create_mock_responses_server_sequence_unchecked(responses).await; - - let codex_home = TempDir::new()?; - write_mock_responses_config_toml_with_chatgpt_base_url( - codex_home.path(), - &server.uri(), - &server.uri(), - )?; - let config_path = codex_home.path().join("config.toml"); - let config_toml = std::fs::read_to_string(&config_path)?; - std::fs::write( - &config_path, - format!("{config_toml}\n[features]\ngeneral_analytics = false\n"), - )?; - mount_analytics_capture(&server, codex_home.path()).await?; - - let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let thread_req = mcp - .send_thread_start_request(ThreadStartParams { - model: Some("mock-model".to_string()), - ..Default::default() - }) - .await?; - let thread_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), - ) - .await??; - let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; - - let turn_req = mcp - .send_turn_start_request(TurnStartParams { - thread_id: thread.id, - input: vec![V2UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }], - ..Default::default() - }) - .await?; - let turn_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), - ) - .await??; - let _ = to_response::(turn_resp)?; - - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), - ) - .await??; - - let turn_event = wait_for_analytics_event( - &server, - std::time::Duration::from_millis(250), - "codex_turn_event", - ) - .await; - assert!( - turn_event.is_err(), - "turn analytics should be gated off when general_analytics is disabled" - ); - Ok(()) -} - #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; @@ -736,6 +674,150 @@ async fn turn_start_rejects_combined_oversized_text_input() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() -> Result<()> { + let codex_home = TempDir::new()?; + let disallowed_write_root = TempDir::new()?; + create_config_toml( + codex_home.path(), + "http://localhost/unused", + "never", + &BTreeMap::from([(Feature::Personality, true)]), + )?; + std::fs::write( + codex_home.path().join("managed_config.toml"), + "sandbox_mode = \"read-only\"\n", + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + let disallowed_write_root = AbsolutePathBuf::from_absolute_path(disallowed_write_root.path()) + .expect("tempdir path should be absolute"); + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + permission_profile: Some(PermissionProfile::Managed { + network: PermissionProfileNetworkPermissions { enabled: false }, + file_system: PermissionProfileFileSystemPermissions::Restricted { + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: disallowed_write_root, + }, + access: FileSystemAccessMode::Write, + }], + glob_scan_max_depth: None, + }, + }), + ..Default::default() + }) + .await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(turn_req)), + ) + .await??; + + assert_eq!(err.error.code, INVALID_REQUEST_ERROR_CODE); + assert!(err.error.message.contains("invalid turn context override")); + assert!( + err.error.message.contains("allowed set [ReadOnly]"), + "unexpected error message: {}", + err.error.message + ); + let turn_started = tokio::time::timeout( + std::time::Duration::from_millis(250), + mcp.read_stream_until_notification_message("turn/started"), + ) + .await; + assert!( + turn_started.is_err(), + "did not expect a turn/started notification after rejected permissionProfile" + ); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_rejects_unknown_environment_before_starting_turn() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + environments: Some(vec![TurnEnvironmentParams { + environment_id: "missing".to_string(), + cwd: codex_home.path().to_path_buf().try_into()?, + }]), + ..Default::default() + }) + .await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(turn_req)), + ) + .await??; + + assert_eq!(err.id, RequestId::Integer(turn_req)); + assert_eq!(err.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!(err.error.message, "unknown turn environment id `missing`"); + let turn_started = tokio::time::timeout( + std::time::Duration::from_millis(250), + mcp.read_stream_until_notification_message("turn/started"), + ) + .await; + assert!( + turn_started.is_err(), + "did not expect a turn/started notification after rejected environments" + ); + + Ok(()) +} + #[tokio::test] async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<()> { // Provide a mock server and config so model wiring is valid. @@ -1733,6 +1815,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { // first turn with workspace-write sandbox and first_cwd let first_turn = mcp .send_turn_start_request(TurnStartParams { + environments: None, thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "first turn".to_string(), @@ -1744,11 +1827,11 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { approvals_reviewer: None, sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { writable_roots: vec![first_cwd.try_into()?], - read_only_access: codex_app_server_protocol::ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, }), + permission_profile: None, model: Some("mock-model".to_string()), effort: Some(ReasoningEffort::Medium), summary: Some(ReasoningSummary::Auto), @@ -1773,6 +1856,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { // second turn with workspace-write and second_cwd, ensure exec begins in second_cwd let second_turn = mcp .send_turn_start_request(TurnStartParams { + environments: None, thread_id: thread.id.clone(), input: vec![V2UserInput::Text { text: "second turn".to_string(), @@ -1783,6 +1867,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), approvals_reviewer: None, sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), + permission_profile: None, model: Some("mock-model".to_string()), effort: Some(ReasoningEffort::Medium), summary: Some(ReasoningSummary::Auto), @@ -1838,6 +1923,179 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_resolves_sticky_thread_environments_and_turn_overrides() -> Result<()> { + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let workspace = tmp.path().join("workspace"); + std::fs::create_dir(&workspace)?; + + let server = create_mock_responses_server_repeating_assistant("done").await; + create_config_toml(&codex_home, &server.uri(), "never", &BTreeMap::default())?; + + let mut mcp = McpProcess::new_with_env( + &codex_home, + &[("CODEX_EXEC_SERVER_URL", Some("http://127.0.0.1:1"))], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + for case in [ + EnvironmentSelectionCase { + name: "sticky_unset_turn_unset", + sticky: None, + turn: None, + }, + EnvironmentSelectionCase { + name: "sticky_empty_turn_unset", + sticky: Some(&[]), + turn: None, + }, + EnvironmentSelectionCase { + name: "sticky_local_turn_unset", + sticky: Some(&["local"]), + turn: None, + }, + EnvironmentSelectionCase { + name: "sticky_remote_turn_unset", + sticky: Some(&["remote"]), + turn: None, + }, + EnvironmentSelectionCase { + name: "sticky_local_remote_turn_unset", + sticky: Some(&["local", "remote"]), + turn: None, + }, + EnvironmentSelectionCase { + name: "sticky_local_turn_empty", + sticky: Some(&["local"]), + turn: Some(&[]), + }, + EnvironmentSelectionCase { + name: "sticky_empty_turn_local", + sticky: Some(&[]), + turn: Some(&["local"]), + }, + EnvironmentSelectionCase { + name: "sticky_local_turn_remote", + sticky: Some(&["local"]), + turn: Some(&["remote"]), + }, + EnvironmentSelectionCase { + name: "sticky_remote_turn_local", + sticky: Some(&["remote"]), + turn: Some(&["local"]), + }, + EnvironmentSelectionCase { + name: "sticky_unset_turn_local_remote", + sticky: None, + turn: Some(&["local", "remote"]), + }, + ] { + run_environment_selection_case(&mut mcp, &workspace, case).await?; + } + + Ok(()) +} + +struct EnvironmentSelectionCase { + name: &'static str, + sticky: Option<&'static [&'static str]>, + turn: Option<&'static [&'static str]>, +} + +async fn run_environment_selection_case( + mcp: &mut McpProcess, + workspace: &Path, + case: EnvironmentSelectionCase, +) -> Result<()> { + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + cwd: Some(workspace.to_string_lossy().into_owned()), + environments: environment_params(case.sticky, workspace)?, + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: format!("run {}", case.name), + text_elements: Vec::new(), + }], + environments: environment_params(case.turn, workspace)?, + cwd: Some(workspace.to_path_buf()), + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let started_notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/started"), + ) + .await??; + let started: TurnStartedNotification = serde_json::from_value( + started_notification + .params + .ok_or_else(|| anyhow::anyhow!("turn/started notification should include params"))?, + )?; + assert_eq!(started.turn.id, turn.id, "{}", case.name); + + let completed_notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = + serde_json::from_value(completed_notification.params.ok_or_else(|| { + anyhow::anyhow!("turn/completed notification should include params") + })?)?; + assert_eq!(completed.turn.id, turn.id, "{}", case.name); + assert_eq!( + completed.turn.status, + TurnStatus::Completed, + "{}", + case.name + ); + + mcp.clear_message_buffer(); + + Ok(()) +} + +fn environment_params( + ids: Option<&[&str]>, + cwd: &Path, +) -> Result>> { + ids.map(|ids| { + ids.iter() + .map(|id| { + Ok(TurnEnvironmentParams { + environment_id: (*id).to_string(), + cwd: cwd.to_path_buf().try_into()?, + }) + }) + .collect() + }) + .transpose() +} + #[tokio::test] async fn turn_start_file_change_approval_v2() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index eda24358ce..31247418e5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -536,7 +536,6 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() approval_policy: Some(codex_app_server_protocol::AskForApproval::UnlessTrusted), sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { writable_roots: vec![workspace.clone().try_into()?], - read_only_access: codex_app_server_protocol::ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 16e28d6cc5..a92b2db528 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -24,7 +24,7 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; -use super::analytics::enable_analytics_capture; +use super::analytics::mount_analytics_capture; use super::analytics::wait_for_analytics_event; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -41,7 +41,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { &server.uri(), &server.uri(), )?; - enable_analytics_capture(&server, &codex_home).await?; + mount_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -125,7 +125,7 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { &server.uri(), &server.uri(), )?; - enable_analytics_capture(&server, &codex_home).await?; + mount_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -234,7 +234,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { &server.uri(), &server.uri(), )?; - enable_analytics_capture(&server, &codex_home).await?; + mount_analytics_capture(&server, &codex_home).await?; let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index 38f88452af..75fefce5cc 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -185,22 +185,39 @@ where // Regular invocation – create a Tokio runtime and execute the provided // async entry-point. let runtime = build_runtime()?; - runtime.block_on(async move { - let current_exe = std::env::current_exe().ok(); - let paths = Arg0DispatchPaths { - codex_self_exe: current_exe.clone(), - codex_linux_sandbox_exe: if cfg!(target_os = "linux") { - linux_sandbox_exe_path(path_entry_guard.as_ref(), current_exe) - } else { - None - }, - main_execve_wrapper_exe: path_entry_guard - .as_ref() - .and_then(|path_entry| path_entry.paths().main_execve_wrapper_exe.clone()), - }; + runtime.block_on(run_main_with_arg0_guard( + path_entry_guard, + std::env::current_exe().ok(), + main_fn, + )) +} - main_fn(paths).await - }) +async fn run_main_with_arg0_guard( + path_entry_guard: Option, + current_exe: Option, + main_fn: F, +) -> anyhow::Result<()> +where + F: FnOnce(Arg0DispatchPaths) -> Fut, + Fut: Future>, +{ + let paths = Arg0DispatchPaths { + codex_self_exe: current_exe.clone(), + codex_linux_sandbox_exe: if cfg!(target_os = "linux") { + linux_sandbox_exe_path(path_entry_guard.as_ref(), current_exe) + } else { + None + }, + main_execve_wrapper_exe: path_entry_guard + .as_ref() + .and_then(|path_entry| path_entry.paths().main_execve_wrapper_exe.clone()), + }; + + let result = main_fn(paths).await; + // Keep the arg0 tempdir guard alive until the async entry point finishes; + // runtime paths above can point at aliases inside that directory. + drop(path_entry_guard); + result } fn linux_sandbox_exe_path( @@ -442,6 +459,10 @@ mod tests { use super::LOCK_FILENAME; use super::janitor_cleanup; use super::linux_sandbox_exe_path; + #[cfg(unix)] + use super::run_main_with_arg0_guard; + #[cfg(unix)] + use anyhow::ensure; use std::fs; use std::fs::File; use std::path::Path; @@ -480,6 +501,49 @@ mod tests { Ok(()) } + #[cfg(unix)] + #[test] + fn run_main_with_arg0_guard_keeps_aliases_alive_until_main_returns() -> anyhow::Result<()> { + let temp_dir = TempDir::new()?; + let alias_path = temp_dir.path().join("codex-helper-alias"); + fs::write(&alias_path, b"")?; + let lock_file = create_lock(temp_dir.path())?; + let path_entry = Arg0PathEntryGuard::new( + temp_dir, + lock_file, + Arg0DispatchPaths { + codex_self_exe: Some(PathBuf::from("/usr/bin/codex")), + codex_linux_sandbox_exe: Some(alias_path.clone()), + main_execve_wrapper_exe: Some(alias_path), + }, + ); + + super::build_runtime()?.block_on(run_main_with_arg0_guard( + /*path_entry_guard*/ Some(path_entry), + Some(PathBuf::from("/usr/bin/codex")), + |paths| async move { + let alias_path = paths + .codex_linux_sandbox_exe + .or(paths.main_execve_wrapper_exe) + .expect("unix dispatch should create at least one alias path"); + ensure!( + alias_path.exists(), + "alias path disappeared before main future was polled: {}", + alias_path.display() + ); + + tokio::task::yield_now().await; + + ensure!( + alias_path.exists(), + "alias path disappeared while main future was running: {}", + alias_path.display() + ); + Ok(()) + }, + )) + } + #[test] fn janitor_skips_dirs_without_lock_file() -> std::io::Result<()> { let root = tempfile::tempdir()?; diff --git a/codex-rs/aws-auth/BUILD.bazel b/codex-rs/aws-auth/BUILD.bazel new file mode 100644 index 0000000000..d278d5599c --- /dev/null +++ b/codex-rs/aws-auth/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "aws-auth", + crate_name = "codex_aws_auth", +) diff --git a/codex-rs/aws-auth/Cargo.toml b/codex-rs/aws-auth/Cargo.toml new file mode 100644 index 0000000000..9e49f7bbe5 --- /dev/null +++ b/codex-rs/aws-auth/Cargo.toml @@ -0,0 +1,26 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-aws-auth" +version.workspace = true + +[lib] +doctest = false +name = "codex_aws_auth" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +aws-config = { workspace = true } +aws-credential-types = { workspace = true } +aws-sigv4 = { workspace = true } +aws-types = { workspace = true } +bytes = { workspace = true } +http = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/codex-rs/aws-auth/src/config.rs b/codex-rs/aws-auth/src/config.rs new file mode 100644 index 0000000000..3d62832e0e --- /dev/null +++ b/codex-rs/aws-auth/src/config.rs @@ -0,0 +1,38 @@ +use aws_config::BehaviorVersion; +use aws_config::SdkConfig; +use aws_credential_types::provider::SharedCredentialsProvider; +use aws_types::region::Region; + +use crate::AwsAuthConfig; +use crate::AwsAuthError; + +pub(crate) async fn load_sdk_config(config: &AwsAuthConfig) -> Result { + if config.service.trim().is_empty() { + return Err(AwsAuthError::EmptyService); + } + + let mut loader = aws_config::defaults(BehaviorVersion::latest()); + if let Some(profile) = config.profile.as_ref() { + loader = loader.profile_name(profile); + } + if let Some(region) = config.region.as_ref() { + loader = loader.region(Region::new(region.clone())); + } + + Ok(loader.load().await) +} + +pub(crate) fn credentials_provider( + sdk_config: &SdkConfig, +) -> Result { + sdk_config + .credentials_provider() + .ok_or(AwsAuthError::MissingCredentialsProvider) +} + +pub(crate) fn resolved_region(sdk_config: &SdkConfig) -> Result { + sdk_config + .region() + .map(ToString::to_string) + .ok_or(AwsAuthError::MissingRegion) +} diff --git a/codex-rs/aws-auth/src/lib.rs b/codex-rs/aws-auth/src/lib.rs new file mode 100644 index 0000000000..13425f2297 --- /dev/null +++ b/codex-rs/aws-auth/src/lib.rs @@ -0,0 +1,261 @@ +mod config; +mod signing; + +use std::time::SystemTime; + +use aws_credential_types::provider::ProvideCredentials; +use aws_credential_types::provider::SharedCredentialsProvider; +use bytes::Bytes; +use http::HeaderMap; +use http::Method; +use thiserror::Error; + +/// AWS auth configuration used to resolve credentials and sign requests. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AwsAuthConfig { + pub profile: Option, + pub region: Option, + pub service: String, +} + +/// Generic HTTP request shape consumed by SigV4 signing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AwsRequestToSign { + pub method: Method, + pub url: String, + pub headers: HeaderMap, + pub body: Bytes, +} + +/// Signed request parts returned to the caller. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AwsSignedRequest { + pub url: String, + pub headers: HeaderMap, +} + +/// Errors returned by credential loading or SigV4 signing. +#[derive(Debug, Error)] +pub enum AwsAuthError { + #[error("AWS service name must not be empty")] + EmptyService, + #[error("AWS SDK config did not resolve a credentials provider")] + MissingCredentialsProvider, + #[error("AWS SDK config did not resolve a region")] + MissingRegion, + #[error("failed to load AWS credentials: {0}")] + Credentials(#[from] aws_credential_types::provider::error::CredentialsError), + #[error("request URL is not a valid URI: {0}")] + InvalidUri(#[source] http::uri::InvalidUri), + #[error("failed to construct HTTP request for signing: {0}")] + BuildHttpRequest(#[source] http::Error), + #[error("request contains a non-UTF8 header value: {0}")] + InvalidHeaderValue(#[source] http::header::ToStrError), + #[error("failed to build signable request: {0}")] + SigningRequest(#[source] aws_sigv4::http_request::SigningError), + #[error("failed to build SigV4 signing params: {0}")] + SigningParams(String), + #[error("SigV4 signing failed: {0}")] + SigningFailure(#[source] aws_sigv4::http_request::SigningError), +} + +/// Loaded AWS auth context that can sign outbound HTTP requests. +#[derive(Clone)] +pub struct AwsAuthContext { + credentials_provider: SharedCredentialsProvider, + region: String, + service: String, +} + +impl std::fmt::Debug for AwsAuthContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AwsAuthContext") + .field("region", &self.region) + .field("service", &self.service) + .finish_non_exhaustive() + } +} + +impl AwsAuthContext { + pub async fn load(config: AwsAuthConfig) -> Result { + let sdk_config = config::load_sdk_config(&config).await?; + let credentials_provider = config::credentials_provider(&sdk_config)?; + let region = config::resolved_region(&sdk_config)?; + + Ok(Self { + credentials_provider, + region, + service: config.service.trim().to_string(), + }) + } + + pub fn region(&self) -> &str { + &self.region + } + + pub fn service(&self) -> &str { + &self.service + } + + pub async fn sign(&self, request: AwsRequestToSign) -> Result { + self.sign_at(request, SystemTime::now()).await + } + + async fn sign_at( + &self, + request: AwsRequestToSign, + time: SystemTime, + ) -> Result { + let credentials = self.credentials_provider.provide_credentials().await?; + signing::sign_request(&credentials, &self.region, &self.service, request, time) + } +} + +impl AwsAuthError { + /// Returns whether retrying the outbound request can reasonably recover from this auth error. + pub fn is_retryable(&self) -> bool { + match self { + AwsAuthError::Credentials(error) => matches!( + error, + aws_credential_types::provider::error::CredentialsError::ProviderTimedOut(_) + | aws_credential_types::provider::error::CredentialsError::ProviderError(_) + ), + AwsAuthError::EmptyService + | AwsAuthError::MissingCredentialsProvider + | AwsAuthError::MissingRegion + | AwsAuthError::InvalidUri(_) + | AwsAuthError::BuildHttpRequest(_) + | AwsAuthError::InvalidHeaderValue(_) + | AwsAuthError::SigningRequest(_) + | AwsAuthError::SigningParams(_) + | AwsAuthError::SigningFailure(_) => false, + } + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + use std::time::UNIX_EPOCH; + + use aws_credential_types::Credentials; + use aws_credential_types::provider::error::CredentialsError; + use pretty_assertions::assert_eq; + + use super::*; + + fn test_context(session_token: Option<&str>) -> AwsAuthContext { + AwsAuthContext { + credentials_provider: SharedCredentialsProvider::new(Credentials::new( + "AKIDEXAMPLE", + "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + session_token.map(str::to_string), + /*expires_after*/ None, + "unit-test", + )), + region: "us-east-1".to_string(), + service: "bedrock".to_string(), + } + } + + fn test_request() -> AwsRequestToSign { + let mut headers = HeaderMap::new(); + headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/json"), + ); + headers.insert("x-test-header", http::HeaderValue::from_static("present")); + AwsRequestToSign { + method: Method::POST, + url: "https://bedrock-runtime.us-east-1.amazonaws.com/v1/responses".to_string(), + headers, + body: Bytes::from_static(br#"{"model":"openai.gpt-oss-120b-1:0"}"#), + } + } + + #[tokio::test] + async fn sign_adds_sigv4_headers_and_preserves_existing_headers() { + let signed = test_context(/*session_token*/ None) + .sign_at( + test_request(), + UNIX_EPOCH + Duration::from_secs(1_700_000_000), + ) + .await + .expect("request should sign"); + + assert_eq!( + signing::header_value(&signed.headers, http::header::CONTENT_TYPE.as_str()), + Some("application/json".to_string()) + ); + assert_eq!( + signing::header_value(&signed.headers, "x-test-header"), + Some("present".to_string()) + ); + assert_eq!( + signed.url, + "https://bedrock-runtime.us-east-1.amazonaws.com/v1/responses" + ); + assert!( + signing::header_value(&signed.headers, http::header::AUTHORIZATION.as_str()) + .is_some_and(|value| value.starts_with("AWS4-HMAC-SHA256 ")) + ); + assert!(signing::header_value(&signed.headers, "x-amz-date").is_some()); + } + + #[test] + fn credentials_provider_failures_are_retryable() { + assert!( + AwsAuthError::Credentials(CredentialsError::provider_error("temporarily unavailable")) + .is_retryable() + ); + assert!( + AwsAuthError::Credentials(CredentialsError::provider_timed_out(Duration::from_secs(1))) + .is_retryable() + ); + } + + #[test] + fn deterministic_aws_auth_errors_are_not_retryable() { + assert!(!AwsAuthError::EmptyService.is_retryable()); + assert!( + !AwsAuthError::Credentials(CredentialsError::not_loaded_no_source()).is_retryable() + ); + assert!( + !AwsAuthError::Credentials(CredentialsError::invalid_configuration("bad profile")) + .is_retryable() + ); + assert!( + !AwsAuthError::Credentials(CredentialsError::unhandled("unexpected response")) + .is_retryable() + ); + } + + #[tokio::test] + async fn sign_includes_session_token_when_credentials_have_one() { + let signed = test_context(Some("session-token")) + .sign_at( + test_request(), + UNIX_EPOCH + Duration::from_secs(1_700_000_000), + ) + .await + .expect("request should sign"); + + assert_eq!( + signing::header_value(&signed.headers, "x-amz-security-token"), + Some("session-token".to_string()) + ); + } + + #[tokio::test] + async fn load_rejects_empty_service_name() { + let err = AwsAuthContext::load(AwsAuthConfig { + profile: None, + region: None, + service: " ".to_string(), + }) + .await + .expect_err("empty service should be rejected"); + + assert_eq!(err.to_string(), "AWS service name must not be empty"); + } +} diff --git a/codex-rs/aws-auth/src/signing.rs b/codex-rs/aws-auth/src/signing.rs new file mode 100644 index 0000000000..ac3d3fd307 --- /dev/null +++ b/codex-rs/aws-auth/src/signing.rs @@ -0,0 +1,76 @@ +use std::str::FromStr; +use std::time::SystemTime; + +use aws_credential_types::Credentials; +use aws_sigv4::http_request::SignableBody; +use aws_sigv4::http_request::SignableRequest; +use aws_sigv4::http_request::SigningSettings; +use aws_sigv4::http_request::sign; +use aws_sigv4::sign::v4; +use http::Request; +use http::Uri; + +use crate::AwsAuthError; +use crate::AwsRequestToSign; +use crate::AwsSignedRequest; + +pub(crate) fn sign_request( + credentials: &Credentials, + region: &str, + service: &str, + request: AwsRequestToSign, + time: SystemTime, +) -> Result { + let signable_headers = request + .headers + .iter() + .map(|(name, value)| { + Ok::<_, AwsAuthError>(( + name.as_str(), + value.to_str().map_err(AwsAuthError::InvalidHeaderValue)?, + )) + }) + .collect::, _>>()?; + let signable_request = SignableRequest::new( + request.method.as_str(), + request.url.as_str(), + signable_headers.into_iter(), + SignableBody::Bytes(request.body.as_ref()), + ) + .map_err(AwsAuthError::SigningRequest)?; + let identity = credentials.clone().into(); + + let signing_params = v4::SigningParams::builder() + .identity(&identity) + .region(region) + .name(service) + .time(time) + .settings(SigningSettings::default()) + .build() + .map_err(|err| AwsAuthError::SigningParams(err.to_string()))?; + let (instructions, _signature) = sign(signable_request, &signing_params.into()) + .map_err(AwsAuthError::SigningFailure)? + .into_parts(); + + let uri = Uri::from_str(&request.url).map_err(AwsAuthError::InvalidUri)?; + let mut http_request = Request::builder() + .method(request.method) + .uri(uri) + .body(()) + .map_err(AwsAuthError::BuildHttpRequest)?; + *http_request.headers_mut() = request.headers; + instructions.apply_to_request_http1x(&mut http_request); + + Ok(AwsSignedRequest { + url: http_request.uri().to_string(), + headers: http_request.headers().clone(), + }) +} + +#[cfg(test)] +pub(crate) fn header_value(headers: &http::HeaderMap, name: &str) -> Option { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_string) +} diff --git a/codex-rs/backend-client/Cargo.toml b/codex-rs/backend-client/Cargo.toml index 1707d45b1b..d2e374ae2a 100644 --- a/codex-rs/backend-client/Cargo.toml +++ b/codex-rs/backend-client/Cargo.toml @@ -17,8 +17,10 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } codex-backend-openapi-models = { path = "../codex-backend-openapi-models" } +codex-api = { workspace = true } codex-client = { workspace = true } codex-login = { workspace = true } +codex-model-provider = { workspace = true } codex-protocol = { workspace = true } [dev-dependencies] diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index b96395b015..6365d527ed 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -5,6 +5,7 @@ use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind; use crate::types::RateLimitStatusPayload; use crate::types::TurnAttemptsSiblingTurnsResponse; use anyhow::Result; +use codex_api::SharedAuthProvider; use codex_client::build_reqwest_client_with_custom_ca; use codex_client::with_chatgpt_cloudflare_cookie_store; use codex_login::CodexAuth; @@ -15,7 +16,6 @@ use codex_protocol::protocol::RateLimitReachedType; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; use reqwest::StatusCode; -use reqwest::header::AUTHORIZATION; use reqwest::header::CONTENT_TYPE; use reqwest::header::HeaderMap; use reqwest::header::HeaderName; @@ -113,17 +113,33 @@ impl PathStyle { } } -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct Client { base_url: String, http: reqwest::Client, - bearer_token: Option, + auth_provider: SharedAuthProvider, user_agent: Option, chatgpt_account_id: Option, chatgpt_account_is_fedramp: bool, path_style: PathStyle, } +impl fmt::Debug for Client { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Client") + .field("base_url", &self.base_url) + .field("auth_provider", &"") + .field("user_agent", &self.user_agent) + .field("chatgpt_account_id", &self.chatgpt_account_id) + .field( + "chatgpt_account_is_fedramp", + &self.chatgpt_account_is_fedramp, + ) + .field("path_style", &self.path_style) + .finish_non_exhaustive() + } +} + impl Client { pub fn new(base_url: impl Into) -> Result { let mut base_url = base_url.into(); @@ -145,7 +161,7 @@ impl Client { Ok(Self { base_url, http, - bearer_token: None, + auth_provider: codex_model_provider::unauthenticated_auth_provider(), user_agent: None, chatgpt_account_id: None, chatgpt_account_is_fedramp: false, @@ -154,21 +170,13 @@ impl Client { } pub fn from_auth(base_url: impl Into, auth: &CodexAuth) -> Result { - let token = auth.get_token().map_err(anyhow::Error::from)?; - let mut client = Self::new(base_url)? + Ok(Self::new(base_url)? .with_user_agent(get_codex_user_agent()) - .with_bearer_token(token); - if let Some(account_id) = auth.get_account_id() { - client = client.with_chatgpt_account_id(account_id); - } - if auth.is_fedramp_account() { - client = client.with_fedramp_routing_header(); - } - Ok(client) + .with_auth_provider(codex_model_provider::auth_provider_from_auth(auth))) } - pub fn with_bearer_token(mut self, token: impl Into) -> Self { - self.bearer_token = Some(token.into()); + pub fn with_auth_provider(mut self, auth: SharedAuthProvider) -> Self { + self.auth_provider = auth; self } @@ -201,12 +209,7 @@ impl Client { } else { h.insert(USER_AGENT, HeaderValue::from_static("codex-cli")); } - if let Some(token) = &self.bearer_token { - let value = format!("Bearer {token}"); - if let Ok(hv) = HeaderValue::from_str(&value) { - h.insert(AUTHORIZATION, hv); - } - } + self.auth_provider.add_auth_headers(&mut h); if let Some(acc) = &self.chatgpt_account_id && let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id") && let Ok(hv) = HeaderValue::from_str(acc) @@ -819,7 +822,7 @@ mod tests { let codex_client = Client { base_url: "https://example.test".to_string(), http: reqwest::Client::new(), - bearer_token: None, + auth_provider: codex_model_provider::unauthenticated_auth_provider(), user_agent: None, chatgpt_account_id: None, chatgpt_account_is_fedramp: false, @@ -833,7 +836,7 @@ mod tests { let chatgpt_client = Client { base_url: "https://chatgpt.com/backend-api".to_string(), http: reqwest::Client::new(), - bearer_token: None, + auth_provider: codex_model_provider::unauthenticated_auth_provider(), user_agent: None, chatgpt_account_id: None, chatgpt_account_is_fedramp: false, diff --git a/codex-rs/chatgpt/Cargo.toml b/codex-rs/chatgpt/Cargo.toml index 354449934a..ce9aa627d4 100644 --- a/codex-rs/chatgpt/Cargo.toml +++ b/codex-rs/chatgpt/Cargo.toml @@ -12,10 +12,10 @@ anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } codex-app-server-protocol = { workspace = true } codex-connectors = { workspace = true } -codex-config = { workspace = true } codex-core = { workspace = true } codex-git-utils = { workspace = true } codex-login = { workspace = true } +codex-model-provider = { workspace = true } codex-utils-cli = { workspace = true } serde = { workspace = true, features = ["derive"] } tokio = { workspace = true, features = ["full"] } diff --git a/codex-rs/chatgpt/src/apply_command.rs b/codex-rs/chatgpt/src/apply_command.rs index 1a9553955d..70fe4481db 100644 --- a/codex-rs/chatgpt/src/apply_command.rs +++ b/codex-rs/chatgpt/src/apply_command.rs @@ -6,7 +6,6 @@ use codex_git_utils::ApplyGitRequest; use codex_git_utils::apply_git_patch; use codex_utils_cli::CliConfigOverrides; -use crate::chatgpt_token::init_chatgpt_token_from_auth; use crate::get_task::GetTaskResponse; use crate::get_task::OutputItem; use crate::get_task::PrOutputItem; @@ -32,9 +31,6 @@ pub async fn run_apply_command( ) .await?; - init_chatgpt_token_from_auth(&config.codex_home, config.cli_auth_credentials_store_mode) - .await?; - let task_response = get_task(&config, apply_cli.task_id).await?; apply_diff_from_task(task_response, cwd).await } diff --git a/codex-rs/chatgpt/src/chatgpt_client.rs b/codex-rs/chatgpt/src/chatgpt_client.rs index fa3a63dadb..05d8186686 100644 --- a/codex-rs/chatgpt/src/chatgpt_client.rs +++ b/codex-rs/chatgpt/src/chatgpt_client.rs @@ -1,9 +1,7 @@ use codex_core::config::Config; +use codex_login::AuthManager; use codex_login::default_client::create_client; -use crate::chatgpt_token::get_chatgpt_token_data; -use crate::chatgpt_token::init_chatgpt_token_from_auth; - use anyhow::Context; use serde::de::DeserializeOwned; use std::time::Duration; @@ -22,24 +20,32 @@ pub(crate) async fn chatgpt_get_request_with_timeout( timeout: Option, ) -> anyhow::Result { let chatgpt_base_url = &config.chatgpt_base_url; - init_chatgpt_token_from_auth(&config.codex_home, config.cli_auth_credentials_store_mode) - .await?; + let auth_manager = + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await; + let auth = auth_manager + .auth() + .await + .ok_or_else(|| anyhow::anyhow!("ChatGPT auth not available"))?; + anyhow::ensure!( + auth.uses_codex_backend(), + "ChatGPT backend requests require Codex backend auth" + ); + anyhow::ensure!( + auth.get_account_id().is_some(), + "ChatGPT account ID not available, please re-run `codex login`" + ); // Make direct HTTP request to ChatGPT backend API with the token let client = create_client(); - let url = format!("{chatgpt_base_url}{path}"); - - let token = - get_chatgpt_token_data().ok_or_else(|| anyhow::anyhow!("ChatGPT token not available"))?; - - let account_id = token.account_id.ok_or_else(|| { - anyhow::anyhow!("ChatGPT account ID not available, please re-run `codex login`") - }); + let url = format!( + "{}/{}", + chatgpt_base_url.trim_end_matches('/'), + path.trim_start_matches('/') + ); let mut request = client .get(&url) - .bearer_auth(&token.access_token) - .header("chatgpt-account-id", account_id?) + .headers(codex_model_provider::auth_provider_from_auth(&auth).to_auth_headers()) .header("Content-Type", "application/json"); if let Some(timeout) = timeout { diff --git a/codex-rs/chatgpt/src/chatgpt_token.rs b/codex-rs/chatgpt/src/chatgpt_token.rs deleted file mode 100644 index d20a7e57c4..0000000000 --- a/codex-rs/chatgpt/src/chatgpt_token.rs +++ /dev/null @@ -1,35 +0,0 @@ -use codex_config::types::AuthCredentialsStoreMode; -use codex_login::AuthManager; -use codex_login::token_data::TokenData; -use std::path::Path; -use std::sync::LazyLock; -use std::sync::RwLock; - -static CHATGPT_TOKEN: LazyLock>> = LazyLock::new(|| RwLock::new(None)); - -pub fn get_chatgpt_token_data() -> Option { - CHATGPT_TOKEN.read().ok()?.clone() -} - -pub fn set_chatgpt_token_data(value: TokenData) { - if let Ok(mut guard) = CHATGPT_TOKEN.write() { - *guard = Some(value); - } -} - -/// Initialize the ChatGPT token from auth.json file -pub async fn init_chatgpt_token_from_auth( - codex_home: &Path, - auth_credentials_store_mode: AuthCredentialsStoreMode, -) -> std::io::Result<()> { - let auth_manager = AuthManager::new( - codex_home.to_path_buf(), - /*enable_codex_api_key_env*/ false, - auth_credentials_store_mode, - ); - if let Some(auth) = auth_manager.auth().await { - let token_data = auth.get_token_data()?; - set_chatgpt_token_data(token_data); - } - Ok(()) -} diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index c054d1b8df..9dba71ce3a 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -2,8 +2,6 @@ use std::collections::HashSet; use std::time::Duration; use crate::chatgpt_client::chatgpt_get_request_with_timeout; -use crate::chatgpt_token::get_chatgpt_token_data; -use crate::chatgpt_token::init_chatgpt_token_from_auth; use codex_app_server_protocol::AppInfo; use codex_connectors::AllConnectorsCacheKey; @@ -23,21 +21,32 @@ use codex_core::plugins::PluginsManager; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::default_client::originator; -use codex_login::token_data::TokenData; const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60); async fn apps_enabled(config: &Config) -> bool { - let auth_manager = AuthManager::shared( - config.codex_home.to_path_buf(), - /*enable_codex_api_key_env*/ false, - config.cli_auth_credentials_store_mode, - ); + let auth_manager = + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await; let auth = auth_manager.auth().await; config .features - .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth)) + .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend)) } + +async fn connector_auth(config: &Config) -> anyhow::Result { + let auth_manager = + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await; + let auth = auth_manager + .auth() + .await + .ok_or_else(|| anyhow::anyhow!("ChatGPT auth not available"))?; + anyhow::ensure!( + auth.uses_codex_backend(), + "ChatGPT connectors require Codex backend auth" + ); + Ok(auth) +} + pub async fn list_connectors(config: &Config) -> anyhow::Result> { if !apps_enabled(config).await { return Ok(Vec::new()); @@ -65,14 +74,8 @@ pub async fn list_cached_all_connectors(config: &Config) -> Option> return Some(Vec::new()); } - if init_chatgpt_token_from_auth(&config.codex_home, config.cli_auth_credentials_store_mode) - .await - .is_err() - { - return None; - } - let token_data = get_chatgpt_token_data()?; - let cache_key = all_connectors_cache_key(config, &token_data); + let auth = connector_auth(config).await.ok()?; + let cache_key = all_connectors_cache_key(config, &auth); let connectors = codex_connectors::cached_all_connectors(&cache_key)?; let connectors = merge_plugin_connectors( connectors, @@ -94,15 +97,11 @@ pub async fn list_all_connectors_with_options( if !apps_enabled(config).await { return Ok(Vec::new()); } - init_chatgpt_token_from_auth(&config.codex_home, config.cli_auth_credentials_store_mode) - .await?; - - let token_data = - get_chatgpt_token_data().ok_or_else(|| anyhow::anyhow!("ChatGPT token not available"))?; - let cache_key = all_connectors_cache_key(config, &token_data); + let auth = connector_auth(config).await?; + let cache_key = all_connectors_cache_key(config, &auth); let connectors = codex_connectors::list_all_connectors_with_options( cache_key, - token_data.id_token.is_workspace_account(), + auth.is_workspace_account(), force_refetch, |path| async move { chatgpt_get_request_with_timeout::( @@ -127,12 +126,12 @@ pub async fn list_all_connectors_with_options( )) } -fn all_connectors_cache_key(config: &Config, token_data: &TokenData) -> AllConnectorsCacheKey { +fn all_connectors_cache_key(config: &Config, auth: &CodexAuth) -> AllConnectorsCacheKey { AllConnectorsCacheKey::new( config.chatgpt_base_url.clone(), - token_data.account_id.clone(), - token_data.id_token.chatgpt_user_id.clone(), - token_data.id_token.is_workspace_account(), + auth.get_account_id(), + auth.get_chatgpt_user_id(), + auth.is_workspace_account(), ) } diff --git a/codex-rs/chatgpt/src/lib.rs b/codex-rs/chatgpt/src/lib.rs index 0d39bb932d..a245265d94 100644 --- a/codex-rs/chatgpt/src/lib.rs +++ b/codex-rs/chatgpt/src/lib.rs @@ -1,5 +1,5 @@ pub mod apply_command; mod chatgpt_client; -mod chatgpt_token; pub mod connectors; pub mod get_task; +pub mod workspace_settings; diff --git a/codex-rs/chatgpt/src/workspace_settings.rs b/codex-rs/chatgpt/src/workspace_settings.rs new file mode 100644 index 0000000000..86e1a40871 --- /dev/null +++ b/codex-rs/chatgpt/src/workspace_settings.rs @@ -0,0 +1,152 @@ +use std::collections::HashMap; +use std::sync::RwLock; +use std::time::Duration; +use std::time::Instant; + +use anyhow::Context; +use codex_core::config::Config; +use codex_login::CodexAuth; +use serde::Deserialize; + +use crate::chatgpt_client::chatgpt_get_request_with_timeout; + +const WORKSPACE_SETTINGS_TIMEOUT: Duration = Duration::from_secs(10); +const WORKSPACE_SETTINGS_CACHE_TTL: Duration = Duration::from_secs(15 * 60); +const CODEX_PLUGINS_BETA_SETTING: &str = "plugins"; + +#[derive(Debug, Deserialize)] +struct WorkspaceSettingsResponse { + #[serde(default)] + beta_settings: HashMap, +} + +#[derive(Debug, Default)] +pub struct WorkspaceSettingsCache { + entry: RwLock>, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct WorkspaceSettingsCacheKey { + chatgpt_base_url: String, + account_id: String, +} + +#[derive(Clone, Debug)] +struct CachedWorkspaceSettings { + key: WorkspaceSettingsCacheKey, + expires_at: Instant, + codex_plugins_enabled: bool, +} + +impl WorkspaceSettingsCache { + fn get_codex_plugins_enabled(&self, key: &WorkspaceSettingsCacheKey) -> Option { + { + let entry = match self.entry.read() { + Ok(entry) => entry, + Err(err) => err.into_inner(), + }; + let now = Instant::now(); + if let Some(cached) = entry.as_ref() + && now < cached.expires_at + && cached.key == *key + { + return Some(cached.codex_plugins_enabled); + } + } + + let mut entry = match self.entry.write() { + Ok(entry) => entry, + Err(err) => err.into_inner(), + }; + let now = Instant::now(); + if entry + .as_ref() + .is_some_and(|cached| now >= cached.expires_at || cached.key != *key) + { + *entry = None; + } + None + } + + fn set_codex_plugins_enabled(&self, key: WorkspaceSettingsCacheKey, enabled: bool) { + let mut entry = match self.entry.write() { + Ok(entry) => entry, + Err(err) => err.into_inner(), + }; + *entry = Some(CachedWorkspaceSettings { + key, + expires_at: Instant::now() + WORKSPACE_SETTINGS_CACHE_TTL, + codex_plugins_enabled: enabled, + }); + } +} + +pub async fn codex_plugins_enabled_for_workspace( + config: &Config, + auth: Option<&CodexAuth>, + cache: Option<&WorkspaceSettingsCache>, +) -> anyhow::Result { + let Some(auth) = auth else { + return Ok(true); + }; + if !auth.is_chatgpt_auth() { + return Ok(true); + } + + let token_data = auth + .get_token_data() + .context("ChatGPT token data is not available")?; + if !token_data.id_token.is_workspace_account() { + return Ok(true); + } + + let Some(account_id) = token_data.account_id.as_deref().filter(|id| !id.is_empty()) else { + return Ok(true); + }; + + let cache_key = WorkspaceSettingsCacheKey { + chatgpt_base_url: config.chatgpt_base_url.clone(), + account_id: account_id.to_string(), + }; + if let Some(cache) = cache + && let Some(enabled) = cache.get_codex_plugins_enabled(&cache_key) + { + return Ok(enabled); + } + + let encoded_account_id = encode_path_segment(account_id); + let settings: WorkspaceSettingsResponse = chatgpt_get_request_with_timeout( + config, + format!("/accounts/{encoded_account_id}/settings"), + Some(WORKSPACE_SETTINGS_TIMEOUT), + ) + .await?; + + let codex_plugins_enabled = settings + .beta_settings + .get(CODEX_PLUGINS_BETA_SETTING) + .copied() + .unwrap_or(true); + + if let Some(cache) = cache { + cache.set_codex_plugins_enabled(cache_key, codex_plugins_enabled); + } + + Ok(codex_plugins_enabled) +} + +fn encode_path_segment(value: &str) -> String { + let mut encoded = String::new(); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(byte as char); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +#[cfg(test)] +#[path = "workspace_settings_tests.rs"] +mod tests; diff --git a/codex-rs/chatgpt/src/workspace_settings_tests.rs b/codex-rs/chatgpt/src/workspace_settings_tests.rs new file mode 100644 index 0000000000..d84cc4c3a2 --- /dev/null +++ b/codex-rs/chatgpt/src/workspace_settings_tests.rs @@ -0,0 +1,17 @@ +use super::*; + +#[test] +fn encode_path_segment_leaves_unreserved_ascii_unchanged() { + assert_eq!( + encode_path_segment("account-123_ABC.~"), + "account-123_ABC.~" + ); +} + +#[test] +fn encode_path_segment_escapes_path_separators_and_spaces() { + assert_eq!( + encode_path_segment("account/123 with space"), + "account%2F123%20with%20space" + ); +} diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 4bd0923533..5f4f3aee10 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -24,12 +24,12 @@ codex-app-server = { workspace = true } codex-app-server-protocol = { workspace = true } codex-app-server-test-client = { workspace = true } codex-arg0 = { workspace = true } -codex-api = { workspace = true } codex-chatgpt = { workspace = true } codex-cloud-tasks = { path = "../cloud-tasks" } codex-utils-cli = { workspace = true } codex-config = { workspace = true } codex-core = { workspace = true } +codex-core-plugins = { workspace = true } codex-exec = { workspace = true } codex-exec-server = { workspace = true } codex-execpolicy = { workspace = true } @@ -38,10 +38,10 @@ codex-login = { workspace = true } codex-mcp = { workspace = true } codex-mcp-server = { workspace = true } codex-models-manager = { workspace = true } -codex-model-provider = { workspace = true } codex-protocol = { workspace = true } codex-responses-api-proxy = { workspace = true } codex-rmcp-client = { workspace = true } +codex-rollout-trace = { workspace = true } codex-sandboxing = { workspace = true } codex-state = { workspace = true } codex-stdio-to-uds = { workspace = true } diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index a2cbf61874..c85da0f5f2 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -152,79 +152,7 @@ async fn run_command_under_sandbox( if let SandboxType::Windows = sandbox_type { #[cfg(target_os = "windows")] { - use codex_core::windows_sandbox::WindowsSandboxLevelExt; - use codex_protocol::config_types::WindowsSandboxLevel; - use codex_windows_sandbox::run_windows_sandbox_capture; - use codex_windows_sandbox::run_windows_sandbox_capture_elevated; - - let policy_str = serde_json::to_string(config.permissions.sandbox_policy.get())?; - - let sandbox_cwd = sandbox_policy_cwd.clone(); - let cwd_clone = cwd.clone(); - let env_map = env.clone(); - let command_vec = command.clone(); - let base_dir = config.codex_home.clone(); - let use_elevated = matches!( - WindowsSandboxLevel::from_config(&config), - WindowsSandboxLevel::Elevated - ); - - // Preflight audit is invoked elsewhere at the appropriate times. - let res = tokio::task::spawn_blocking(move || { - if use_elevated { - run_windows_sandbox_capture_elevated( - codex_windows_sandbox::ElevatedSandboxCaptureRequest { - policy_json_or_preset: policy_str.as_str(), - sandbox_policy_cwd: &sandbox_cwd, - codex_home: base_dir.as_path(), - command: command_vec, - cwd: &cwd_clone, - env_map, - timeout_ms: None, - use_private_desktop: config.permissions.windows_sandbox_private_desktop, - proxy_enforced: false, - read_roots_override: None, - write_roots_override: None, - deny_write_paths_override: &[], - }, - ) - } else { - run_windows_sandbox_capture( - policy_str.as_str(), - &sandbox_cwd, - base_dir.as_path(), - command_vec, - &cwd_clone, - env_map, - /*timeout_ms*/ None, - config.permissions.windows_sandbox_private_desktop, - ) - } - }) - .await; - - let capture = match res { - Ok(Ok(v)) => v, - Ok(Err(err)) => { - eprintln!("windows sandbox failed: {err}"); - std::process::exit(1); - } - Err(join_err) => { - eprintln!("windows sandbox join error: {join_err}"); - std::process::exit(1); - } - }; - - if !capture.stdout.is_empty() { - use std::io::Write; - let _ = std::io::stdout().write_all(&capture.stdout); - } - if !capture.stderr.is_empty() { - use std::io::Write; - let _ = std::io::stderr().write_all(&capture.stderr); - } - - std::process::exit(capture.exit_code); + run_command_under_windows_session(&config, command, cwd, sandbox_policy_cwd, env).await; } #[cfg(not(target_os = "windows"))] { @@ -243,7 +171,7 @@ async fn run_command_under_sandbox( let network_proxy = match config.permissions.network.as_ref() { Some(spec) => Some( spec.start_proxy( - config.permissions.sandbox_policy.get(), + config.permissions.permission_profile.get(), /*policy_decider*/ None, /*blocked_request_observer*/ None, managed_network_requirements_enabled, @@ -261,22 +189,23 @@ async fn run_command_under_sandbox( let mut child = match sandbox_type { #[cfg(target_os = "macos")] SandboxType::Seatbelt => { + let file_system_sandbox_policy = config.permissions.file_system_sandbox_policy(); + let network_sandbox_policy = config.permissions.network_sandbox_policy(); let args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams { command, - file_system_sandbox_policy: &config.permissions.file_system_sandbox_policy, - network_sandbox_policy: config.permissions.network_sandbox_policy, + file_system_sandbox_policy: &file_system_sandbox_policy, + network_sandbox_policy, sandbox_policy_cwd: sandbox_policy_cwd.as_path(), enforce_managed_network: false, network: network.as_ref(), extra_allow_unix_sockets: allow_unix_sockets, }); - let network_policy = config.permissions.network_sandbox_policy; spawn_debug_sandbox_child( PathBuf::from("/usr/bin/sandbox-exec"), args, /*arg0*/ None, cwd.to_path_buf(), - network_policy, + network_sandbox_policy, env, |env_map| { env_map.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); @@ -293,23 +222,26 @@ async fn run_command_under_sandbox( .codex_linux_sandbox_exe .expect("codex-linux-sandbox executable not found"); let use_legacy_landlock = config.features.use_legacy_landlock(); + let file_system_sandbox_policy = config.permissions.file_system_sandbox_policy(); + let network_sandbox_policy = config.permissions.network_sandbox_policy(); let args = create_linux_sandbox_command_args_for_policies( command, cwd.as_path(), - config.permissions.sandbox_policy.get(), - &config.permissions.file_system_sandbox_policy, - config.permissions.network_sandbox_policy, + &config + .permissions + .legacy_sandbox_policy(sandbox_policy_cwd.as_path()), + &file_system_sandbox_policy, + network_sandbox_policy, sandbox_policy_cwd.as_path(), use_legacy_landlock, /*allow_network_for_proxy*/ false, ); - let network_policy = config.permissions.network_sandbox_policy; spawn_debug_sandbox_child( codex_linux_sandbox_exe, args, Some("codex-linux-sandbox"), cwd.to_path_buf(), - network_policy, + network_sandbox_policy, env, |env_map| { if let Some(network) = network.as_ref() { @@ -347,6 +279,133 @@ async fn run_command_under_sandbox( handle_exit_status(status); } +#[cfg(target_os = "windows")] +async fn run_command_under_windows_session( + config: &Config, + command: Vec, + cwd: AbsolutePathBuf, + sandbox_policy_cwd: AbsolutePathBuf, + env: std::collections::HashMap, +) -> ! { + use codex_core::windows_sandbox::WindowsSandboxLevelExt; + use codex_protocol::config_types::WindowsSandboxLevel; + use codex_windows_sandbox::spawn_windows_sandbox_session_elevated; + use codex_windows_sandbox::spawn_windows_sandbox_session_legacy; + + let sandbox_policy = config + .permissions + .legacy_sandbox_policy(sandbox_policy_cwd.as_path()); + let policy_str = match serde_json::to_string(&sandbox_policy) { + Ok(policy_str) => policy_str, + Err(err) => { + eprintln!("windows sandbox failed to serialize policy: {err}"); + std::process::exit(1); + } + }; + + let use_elevated = matches!( + WindowsSandboxLevel::from_config(config), + WindowsSandboxLevel::Elevated + ); + + let spawned = if use_elevated { + spawn_windows_sandbox_session_elevated( + policy_str.as_str(), + sandbox_policy_cwd.as_path(), + config.codex_home.as_path(), + command, + cwd.as_path(), + env, + None, + /*tty*/ false, + /*stdin_open*/ true, + config.permissions.windows_sandbox_private_desktop, + ) + .await + } else { + spawn_windows_sandbox_session_legacy( + policy_str.as_str(), + sandbox_policy_cwd.as_path(), + config.codex_home.as_path(), + command, + cwd.as_path(), + env, + None, + /*tty*/ false, + /*stdin_open*/ true, + config.permissions.windows_sandbox_private_desktop, + ) + .await + }; + + let spawned = match spawned { + Ok(spawned) => spawned, + Err(err) => { + eprintln!("windows sandbox failed: {err}"); + std::process::exit(1); + } + }; + + let session = std::sync::Arc::new(spawned.session); + let tokio_runtime = tokio::runtime::Handle::current(); + // Give large or slow tail output a better chance to finish draining + // without letting rare EOF issues hang the wrapper indefinitely. + let output_drain_timeout = std::time::Duration::from_secs(5); + // A helper thread watches our stdin. When the input source closes it, + // the thread tells the main async code so we can also close stdin for + // the sandboxed child process. + let (stdin_eof_tx, stdin_eof_rx) = tokio::sync::oneshot::channel(); + + // Start background threads that copy stdin/stdout/stderr. We + // intentionally do not keep their JoinHandles; dropping the handle does + // not stop the thread, it just means we are not going to wait on it + // later. + drop(windows_stdio_bridge::spawn_input_forwarder( + std::io::stdin(), + session.writer_sender(), + stdin_eof_tx, + )); + let (stdout_forwarder, stdout_forwarder_done_rx) = windows_stdio_bridge::spawn_output_forwarder( + tokio_runtime.clone(), + spawned.stdout_rx, + std::io::stdout(), + ); + drop(stdout_forwarder); + let (stderr_forwarder, stderr_forwarder_done_rx) = windows_stdio_bridge::spawn_output_forwarder( + tokio_runtime.clone(), + spawned.stderr_rx, + std::io::stderr(), + ); + drop(stderr_forwarder); + + let stdin_close_task = tokio::spawn({ + let session = std::sync::Arc::clone(&session); + async move { + let _ = stdin_eof_rx.await; + session.close_stdin(); + } + }); + + let mut exit_rx = spawned.exit_rx; + let exit_code = tokio::select! { + res = &mut exit_rx => res.unwrap_or(-1), + res = tokio::signal::ctrl_c() => { + if let Ok(()) = res { + session.request_terminate(); + } + exit_rx.await.unwrap_or(-1) + } + }; + + stdin_close_task.abort(); + let _ = tokio::time::timeout(output_drain_timeout, async { + let _ = stdout_forwarder_done_rx.await; + let _ = stderr_forwarder_done_rx.await; + }) + .await; + std::process::exit(exit_code); +} + pub fn create_sandbox_mode(full_auto: bool) -> SandboxMode { if full_auto { SandboxMode::WorkspaceWrite @@ -386,6 +445,141 @@ async fn spawn_debug_sandbox_child( .spawn() } +#[cfg(target_os = "windows")] +mod windows_stdio_bridge { + use std::io::Read; + use std::io::Write; + + use tokio::sync::mpsc; + use tokio::sync::oneshot; + + const STDIN_FORWARD_CHUNK_SIZE: usize = 8 * 1024; + + pub(super) fn spawn_input_forwarder( + mut input: R, + writer_tx: mpsc::Sender>, + stdin_eof_tx: oneshot::Sender<()>, + ) -> std::thread::JoinHandle<()> + where + R: Read + Send + 'static, + { + std::thread::spawn(move || { + let mut buffer = [0_u8; STDIN_FORWARD_CHUNK_SIZE]; + loop { + match input.read(&mut buffer) { + Ok(0) => break, + Ok(n) => { + if writer_tx.blocking_send(buffer[..n].to_vec()).is_err() { + break; + } + } + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, + Err(err) => { + eprintln!("windows sandbox stdin forwarder failed: {err}"); + break; + } + } + } + let _ = stdin_eof_tx.send(()); + }) + } + + pub(super) fn spawn_output_forwarder( + tokio_runtime: tokio::runtime::Handle, + output_rx: mpsc::Receiver>, + mut writer: W, + ) -> (std::thread::JoinHandle<()>, oneshot::Receiver<()>) + where + W: Write + Send + 'static, + { + let (done_tx, done_rx) = oneshot::channel(); + // The sandbox session emits output on Tokio channels, but writing to the + // caller's stdio is simplest from a dedicated blocking thread. + let handle = std::thread::spawn(move || { + let mut output_rx = output_rx; + while let Some(chunk) = tokio_runtime.block_on(output_rx.recv()) { + if let Err(err) = writer.write_all(&chunk) { + eprintln!("windows sandbox output forwarder failed to write: {err}"); + break; + } + if let Err(err) = writer.flush() { + eprintln!("windows sandbox output forwarder failed to flush: {err}"); + break; + } + } + let _ = done_tx.send(()); + }); + (handle, done_rx) + } + + #[cfg(test)] + mod tests { + use std::sync::Mutex; + + use pretty_assertions::assert_eq; + + use super::*; + + #[tokio::test] + async fn input_forwarder_sends_chunks_and_reports_eof() -> anyhow::Result<()> { + let (writer_tx, mut writer_rx) = tokio::sync::mpsc::channel::>(4); + let (stdin_closed_tx, stdin_closed_rx) = tokio::sync::oneshot::channel(); + let input = std::io::Cursor::new(b"first\nsecond\n".to_vec()); + + let forwarder = spawn_input_forwarder(input, writer_tx, stdin_closed_tx); + let mut received = Vec::new(); + while let Some(chunk) = writer_rx.recv().await { + received.extend_from_slice(&chunk); + } + stdin_closed_rx.await?; + forwarder.join().expect("stdin forwarder should finish"); + + assert_eq!(received, b"first\nsecond\n".to_vec()); + Ok(()) + } + + #[tokio::test] + async fn output_forwarder_writes_all_chunks() -> anyhow::Result<()> { + #[derive(Clone, Default)] + struct SharedWriter(std::sync::Arc>>); + + impl std::io::Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let mut guard = self + .0 + .lock() + .map_err(|_| std::io::Error::other("writer poisoned"))?; + guard.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + let runtime = tokio::runtime::Handle::current(); + let (output_tx, output_rx) = tokio::sync::mpsc::channel::>(4); + let writer = SharedWriter::default(); + let sink = std::sync::Arc::clone(&writer.0); + + let (forwarder, done_rx) = spawn_output_forwarder(runtime, output_rx, writer); + output_tx.send(b"alpha".to_vec()).await?; + output_tx.send(b"beta".to_vec()).await?; + drop(output_tx); + forwarder.join().expect("output forwarder should finish"); + done_rx.await?; + + let output = sink + .lock() + .map_err(|_| anyhow::anyhow!("writer poisoned"))? + .clone(); + assert_eq!(output, b"alphabeta".to_vec()); + Ok(()) + } + } +} + async fn load_debug_sandbox_config( cli_overrides: Vec<(String, TomlValue)>, codex_linux_sandbox_exe: Option, @@ -528,17 +722,17 @@ mod tests { assert!(config_uses_permission_profiles(&config)); assert!( - profile_config.permissions.file_system_sandbox_policy - != legacy_config.permissions.file_system_sandbox_policy, + profile_config.permissions.file_system_sandbox_policy() + != legacy_config.permissions.file_system_sandbox_policy(), "test fixture should distinguish profile syntax from legacy sandbox_mode" ); assert_eq!( - config.permissions.file_system_sandbox_policy, - profile_config.permissions.file_system_sandbox_policy, + config.permissions.file_system_sandbox_policy(), + profile_config.permissions.file_system_sandbox_policy(), ); assert_ne!( - config.permissions.file_system_sandbox_policy, - legacy_config.permissions.file_system_sandbox_policy, + config.permissions.file_system_sandbox_policy(), + legacy_config.permissions.file_system_sandbox_policy(), ); Ok(()) diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index cac34b3b61..3f3448c64c 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -9,8 +9,10 @@ use codex_utils_cli::CliConfigOverrides; pub use debug_sandbox::run_command_under_landlock; pub use debug_sandbox::run_command_under_seatbelt; pub use debug_sandbox::run_command_under_windows; +pub use login::read_agent_identity_from_stdin; pub use login::read_api_key_from_stdin; pub use login::run_login_status; +pub use login::run_login_with_agent_identity; pub use login::run_login_with_api_key; pub use login::run_login_with_chatgpt; pub use login::run_login_with_device_code; diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index fd0dfee3af..5c60c20cbf 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -13,6 +13,7 @@ use codex_core::config::Config; use codex_login::CLIENT_ID; use codex_login::CodexAuth; use codex_login::ServerOptions; +use codex_login::login_with_agent_identity; use codex_login::login_with_api_key; use codex_login::logout_with_revoke; use codex_login::run_device_code_login; @@ -34,6 +35,8 @@ const CHATGPT_LOGIN_DISABLED_MESSAGE: &str = "ChatGPT login is disabled. Use API key login instead."; const API_KEY_LOGIN_DISABLED_MESSAGE: &str = "API key login is disabled. Use ChatGPT login instead."; +const AGENT_IDENTITY_LOGIN_DISABLED_MESSAGE: &str = + "Agent Identity login is disabled. Use API key login instead."; const LOGIN_SUCCESS_MESSAGE: &str = "Successfully logged in"; /// Installs a small file-backed tracing layer for direct `codex login` flows. @@ -187,31 +190,74 @@ pub async fn run_login_with_api_key( } } +pub async fn run_login_with_agent_identity( + cli_config_overrides: CliConfigOverrides, + agent_identity: String, +) -> ! { + let config = load_config_or_exit(cli_config_overrides).await; + let _login_log_guard = init_login_file_logging(&config); + tracing::info!("starting agent identity login flow"); + + if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { + eprintln!("{AGENT_IDENTITY_LOGIN_DISABLED_MESSAGE}"); + std::process::exit(1); + } + + match login_with_agent_identity( + &config.codex_home, + &agent_identity, + config.cli_auth_credentials_store_mode, + ) { + Ok(_) => { + eprintln!("{LOGIN_SUCCESS_MESSAGE}"); + std::process::exit(0); + } + Err(e) => { + eprintln!("Error logging in with Agent Identity: {e}"); + std::process::exit(1); + } + } +} + pub fn read_api_key_from_stdin() -> String { + read_stdin_secret( + "--with-api-key expects the API key on stdin. Try piping it, e.g. `printenv OPENAI_API_KEY | codex login --with-api-key`.", + "Reading API key from stdin...", + "No API key provided via stdin.", + ) +} + +pub fn read_agent_identity_from_stdin() -> String { + read_stdin_secret( + "--with-agent-identity expects the Agent Identity token on stdin. Try piping it, e.g. `printenv CODEX_AGENT_IDENTITY | codex login --with-agent-identity`.", + "Reading Agent Identity token from stdin...", + "No Agent Identity token provided via stdin.", + ) +} + +fn read_stdin_secret(terminal_message: &str, reading_message: &str, empty_message: &str) -> String { let mut stdin = std::io::stdin(); if stdin.is_terminal() { - eprintln!( - "--with-api-key expects the API key on stdin. Try piping it, e.g. `printenv OPENAI_API_KEY | codex login --with-api-key`." - ); + eprintln!("{terminal_message}"); std::process::exit(1); } - eprintln!("Reading API key from stdin..."); + eprintln!("{reading_message}"); let mut buffer = String::new(); if let Err(err) = stdin.read_to_string(&mut buffer) { - eprintln!("Failed to read API key from stdin: {err}"); + eprintln!("Failed to read stdin: {err}"); std::process::exit(1); } - let api_key = buffer.trim().to_string(); - if api_key.is_empty() { - eprintln!("No API key provided via stdin."); + let secret = buffer.trim().to_string(); + if secret.is_empty() { + eprintln!("{empty_message}"); std::process::exit(1); } - api_key + secret } /// Login using the OAuth device code flow. @@ -316,7 +362,9 @@ pub async fn run_login_with_device_code_fallback_to_browser( pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; - match CodexAuth::from_auth_storage(&config.codex_home, config.cli_auth_credentials_store_mode) { + match CodexAuth::from_auth_storage(&config.codex_home, config.cli_auth_credentials_store_mode) + .await + { Ok(Some(auth)) => match auth.auth_mode() { AuthMode::ApiKey => match auth.get_token() { Ok(api_key) => { @@ -332,6 +380,10 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { eprintln!("Logged in using ChatGPT"); std::process::exit(0); } + AuthMode::AgentIdentity => { + eprintln!("Logged in using Agent Identity"); + std::process::exit(0); + } }, Ok(None) => { eprintln!("Not logged in"); diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 9852a2cd5f..852dff616d 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -10,8 +10,10 @@ use codex_chatgpt::apply_command::run_apply_command; use codex_cli::LandlockCommand; use codex_cli::SeatbeltCommand; use codex_cli::WindowsCommand; +use codex_cli::read_agent_identity_from_stdin; use codex_cli::read_api_key_from_stdin; use codex_cli::run_login_status; +use codex_cli::run_login_with_agent_identity; use codex_cli::run_login_with_api_key; use codex_cli::run_login_with_chatgpt; use codex_cli::run_login_with_device_code; @@ -22,12 +24,15 @@ use codex_exec::Command as ExecCommand; use codex_exec::ReviewArgs; use codex_execpolicy::ExecPolicyCheckCommand; use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; +use codex_rollout_trace::REDUCED_STATE_FILE_NAME; +use codex_rollout_trace::replay_bundle; use codex_state::StateRuntime; use codex_state::state_db_path; use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; use codex_tui::ExitReason; use codex_tui::UpdateAction; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; use owo_colors::OwoColorize; use std::io::IsTerminal; @@ -40,14 +45,11 @@ mod app_cmd; mod desktop_app; mod marketplace_cmd; mod mcp_cmd; -mod responses_cmd; #[cfg(not(windows))] mod wsl_paths; use crate::marketplace_cmd::MarketplaceCli; use crate::mcp_cmd::McpCli; -use crate::responses_cmd::ResponsesCommand; -use crate::responses_cmd::run_responses_command; use codex_core::build_models_manager; use codex_core::clear_memory_roots_contents; @@ -58,7 +60,7 @@ use codex_core::config::find_codex_home; use codex_features::FEATURES; use codex_features::Stage; use codex_features::is_known_feature_key; -use codex_models_manager::AuthManager; +use codex_login::AuthManager; use codex_models_manager::bundled_models_response; use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_models_manager::manager::RefreshStrategy; @@ -160,10 +162,6 @@ enum Subcommand { #[clap(hide = true)] ResponsesApiProxy(ResponsesApiProxyArgs), - /// Internal: send one raw Responses API payload through Codex auth. - #[clap(hide = true)] - Responses(ResponsesCommand), - /// Internal: relay stdio to a Unix domain socket. #[clap(hide = true, name = "stdio-to-uds")] StdioToUds(StdioToUdsCommand), @@ -176,6 +174,7 @@ enum Subcommand { } #[derive(Debug, Parser)] +#[command(bin_name = "codex plugin")] struct PluginCli { #[clap(flatten)] pub config_overrides: CliConfigOverrides, @@ -214,6 +213,10 @@ enum DebugSubcommand { /// Render the model-visible prompt input list as JSON. PromptInput(DebugPromptInputCommand), + /// Replay a rollout trace bundle and write reduced state JSON. + #[clap(hide = true)] + TraceReduce(DebugTraceReduceCommand), + /// Internal: reset local memory state for a fresh start. #[clap(hide = true)] ClearMemories, @@ -255,6 +258,17 @@ struct DebugModelsCommand { bundled: bool, } +#[derive(Debug, Parser)] +struct DebugTraceReduceCommand { + /// Trace bundle directory containing manifest.json and trace.jsonl. + #[arg(value_name = "TRACE_BUNDLE")] + trace_bundle: PathBuf, + + /// Output path for reduced RolloutTrace JSON. Defaults to TRACE_BUNDLE/state.json. + #[arg(long = "output", short = 'o', value_name = "FILE")] + output: Option, +} + #[derive(Debug, Parser)] struct ResumeCommand { /// Conversation/session id (UUID) or thread name. UUIDs take precedence if it parses. @@ -347,6 +361,12 @@ struct LoginCommand { )] with_api_key: bool, + #[arg( + long = "with-agent-identity", + help = "Read the experimental Agent Identity token from stdin (e.g. `printenv CODEX_AGENT_IDENTITY | codex login --with-agent-identity`)" + )] + with_agent_identity: bool, + #[arg( long = "api-key", num_args = 0..=1, @@ -392,7 +412,7 @@ struct AppServerCommand { subcommand: Option, /// Transport endpoint URL. Supported values: `stdio://` (default), - /// `ws://IP:PORT`, `off`. + /// `unix://`, `unix://PATH`, `ws://IP:PORT`, `off`. #[arg( long = "listen", value_name = "URL", @@ -436,6 +456,9 @@ struct ExecServerCommand { #[derive(Debug, clap::Subcommand)] #[allow(clippy::enum_variant_names)] enum AppServerSubcommand { + /// Proxy stdio bytes to the running app-server control socket. + Proxy(AppServerProxyCommand), + /// [experimental] Generate TypeScript bindings for the app server protocol. GenerateTs(GenerateTsCommand), @@ -447,6 +470,13 @@ enum AppServerSubcommand { GenerateInternalJsonSchema(GenerateInternalJsonSchemaCommand), } +#[derive(Debug, Args)] +struct AppServerProxyCommand { + /// Path to the app-server Unix domain socket to connect to. + #[arg(long = "sock", value_name = "SOCKET_PATH", value_parser = parse_socket_path)] + socket_path: Option, +} + #[derive(Debug, Args)] struct GenerateTsCommand { /// Output directory where .ts files will be written @@ -483,8 +513,13 @@ struct GenerateInternalJsonSchemaCommand { #[derive(Debug, Parser)] struct StdioToUdsCommand { /// Path to the Unix domain socket to connect to. - #[arg(value_name = "SOCKET_PATH")] - socket_path: PathBuf, + #[arg(value_name = "SOCKET_PATH", value_parser = parse_socket_path)] + socket_path: AbsolutePathBuf, +} + +fn parse_socket_path(raw: &str) -> Result { + AbsolutePathBuf::relative_to_current_dir(raw) + .map_err(|err| format!("failed to resolve socket path `{raw}`: {err}")) } fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec { @@ -795,7 +830,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { codex_app_server::run_main_with_transport( arg0_paths.clone(), root_config_overrides, - codex_core::config_loader::LoaderOverrides::default(), + codex_config::LoaderOverrides::default(), analytics_default_enabled, transport, codex_protocol::protocol::SessionSource::VSCode, @@ -803,6 +838,16 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { ) .await?; } + Some(AppServerSubcommand::Proxy(proxy_cli)) => { + let socket_path = match proxy_cli.socket_path { + Some(socket_path) => socket_path, + None => { + let codex_home = find_codex_home()?; + codex_app_server::app_server_control_socket_path(&codex_home)? + } + }; + codex_stdio_to_uds::run(socket_path.as_path()).await?; + } Some(AppServerSubcommand::GenerateTs(gen_cli)) => { let options = codex_app_server_protocol::GenerateTsOptions { experimental_api: gen_cli.experimental, @@ -903,7 +948,12 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { run_login_status(login_cli.config_overrides).await; } None => { - if login_cli.use_device_code { + if login_cli.with_api_key && login_cli.with_agent_identity { + eprintln!( + "Choose one login credential source: --with-api-key or --with-agent-identity." + ); + std::process::exit(1); + } else if login_cli.use_device_code { run_login_with_device_code( login_cli.config_overrides, login_cli.issuer_base_url, @@ -918,6 +968,10 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } else if login_cli.with_api_key { let api_key = read_api_key_from_stdin(); run_login_with_api_key(login_cli.config_overrides, api_key).await; + } else if login_cli.with_agent_identity { + let agent_identity = read_agent_identity_from_stdin(); + run_login_with_agent_identity(login_cli.config_overrides, agent_identity) + .await; } else { run_login_with_chatgpt(login_cli.config_overrides).await; } @@ -1038,6 +1092,14 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { ) .await?; } + DebugSubcommand::TraceReduce(cmd) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "debug trace-reduce", + )?; + run_debug_trace_reduce_command(cmd).await?; + } DebugSubcommand::ClearMemories => { reject_remote_mode_for_subcommand( root_remote.as_deref(), @@ -1078,14 +1140,6 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args)) .await??; } - Some(Subcommand::Responses(ResponsesCommand {})) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "responses", - )?; - run_responses_command(root_config_overrides).await?; - } Some(Subcommand::StdioToUds(cmd)) => { reject_remote_mode_for_subcommand( root_remote.as_deref(), @@ -1238,6 +1292,19 @@ fn maybe_print_under_development_feature_warning( ); } +async fn run_debug_trace_reduce_command(cmd: DebugTraceReduceCommand) -> anyhow::Result<()> { + let output = cmd + .output + .unwrap_or_else(|| cmd.trace_bundle.join(REDUCED_STATE_FILE_NAME)); + + let trace = replay_bundle(&cmd.trace_bundle)?; + let reduced_json = serde_json::to_vec_pretty(&trace)?; + tokio::fs::write(&output, reduced_json).await?; + println!("{}", output.display()); + + Ok(()) +} + async fn run_debug_prompt_input_command( cmd: DebugPromptInputCommand, root_config_overrides: CliConfigOverrides, @@ -1317,7 +1384,7 @@ async fn run_debug_models_command( .map_err(anyhow::Error::msg)?; let config = Config::load_with_cli_overrides(cli_overrides).await?; let auth_manager = - AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ true); + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ true).await; let models_manager = build_models_manager(&config, auth_manager, CollaborationModesConfig::default()); models_manager @@ -1407,6 +1474,7 @@ fn reject_remote_mode_for_app_server_subcommand( ) -> anyhow::Result<()> { let subcommand_name = match subcommand { None => "app-server", + Some(AppServerSubcommand::Proxy(_)) => "app-server proxy", Some(AppServerSubcommand::GenerateTs(_)) => "app-server generate-ts", Some(AppServerSubcommand::GenerateJsonSchema(_)) => "app-server generate-json-schema", Some(AppServerSubcommand::GenerateInternalJsonSchema(_)) => { @@ -1483,7 +1551,7 @@ async fn run_interactive_tui( codex_tui::run_main( interactive, arg0_paths, - codex_core::config_loader::LoaderOverrides::default(), + codex_config::LoaderOverrides::default(), normalized_remote, remote_auth_token, ) @@ -1723,6 +1791,12 @@ mod tests { app_server } + fn default_app_server_socket_path() -> AbsolutePathBuf { + let codex_home = find_codex_home().expect("codex home"); + codex_app_server::app_server_control_socket_path(&codex_home) + .expect("default app-server socket path") + } + #[test] fn debug_prompt_input_parses_prompt_and_images() { let cli = MultitoolCli::try_parse_from([ @@ -1765,12 +1839,37 @@ mod tests { } #[test] - fn responses_subcommand_is_hidden_from_help_but_parses() { - let help = MultitoolCli::command().render_help().to_string(); - assert!(!help.contains("responses")); + fn responses_subcommand_is_not_registered() { + let command = MultitoolCli::command(); + assert!( + command + .get_subcommands() + .all(|subcommand| subcommand.get_name() != "responses") + ); + } - let cli = MultitoolCli::try_parse_from(["codex", "responses"]).expect("parse"); - assert!(matches!(cli.subcommand, Some(Subcommand::Responses(_)))); + fn help_from_args(args: &[&str]) -> String { + let err = MultitoolCli::try_parse_from(args).expect_err("help should short-circuit"); + assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp); + err.to_string() + } + + #[test] + fn plugin_marketplace_help_uses_plugin_namespace() { + let help = help_from_args(&["codex", "plugin", "marketplace", "--help"]); + assert!( + help.contains("Usage: codex plugin marketplace [OPTIONS] "), + "{help}" + ); + + for (subcommand, usage) in [ + ("add", "Usage: codex plugin marketplace add"), + ("upgrade", "Usage: codex plugin marketplace upgrade"), + ("remove", "Usage: codex plugin marketplace remove"), + ] { + let help = help_from_args(&["codex", "plugin", "marketplace", subcommand, "--help"]); + assert!(help.contains(usage), "{help}"); + } } #[test] @@ -2198,6 +2297,32 @@ mod tests { ); } + #[test] + fn app_server_listen_unix_socket_url_parses() { + let app_server = + app_server_from_args(["codex", "app-server", "--listen", "unix://"].as_ref()); + assert_eq!( + app_server.listen, + codex_app_server::AppServerTransport::UnixSocket { + socket_path: default_app_server_socket_path() + } + ); + } + + #[test] + fn app_server_listen_unix_socket_path_parses() { + let app_server = app_server_from_args( + ["codex", "app-server", "--listen", "unix:///tmp/codex.sock"].as_ref(), + ); + assert_eq!( + app_server.listen, + codex_app_server::AppServerTransport::UnixSocket { + socket_path: AbsolutePathBuf::from_absolute_path("/tmp/codex.sock") + .expect("absolute path should parse") + } + ); + } + #[test] fn app_server_listen_off_parses() { let app_server = app_server_from_args(["codex", "app-server", "--listen", "off"].as_ref()); @@ -2211,6 +2336,45 @@ mod tests { assert!(parse_result.is_err()); } + #[test] + fn app_server_proxy_subcommand_parses() { + let app_server = app_server_from_args(["codex", "app-server", "proxy"].as_ref()); + assert!(matches!( + app_server.subcommand, + Some(AppServerSubcommand::Proxy(AppServerProxyCommand { + socket_path: None + })) + )); + } + + #[test] + fn app_server_proxy_sock_path_parses() { + let app_server = + app_server_from_args(["codex", "app-server", "proxy", "--sock", "codex.sock"].as_ref()); + let Some(AppServerSubcommand::Proxy(proxy)) = app_server.subcommand else { + panic!("expected proxy subcommand"); + }; + assert_eq!( + proxy.socket_path, + Some( + AbsolutePathBuf::relative_to_current_dir("codex.sock") + .expect("relative path should resolve") + ) + ); + } + + #[test] + fn reject_remote_auth_token_env_for_app_server_proxy() { + let subcommand = AppServerSubcommand::Proxy(AppServerProxyCommand { socket_path: None }); + let err = reject_remote_mode_for_app_server_subcommand( + /*remote*/ None, + Some("CODEX_REMOTE_AUTH_TOKEN"), + Some(&subcommand), + ) + .expect_err("app-server proxy should reject --remote-auth-token-env"); + assert!(err.to_string().contains("app-server proxy")); + } + #[test] fn app_server_capability_token_flags_parse() { let app_server = app_server_from_args( diff --git a/codex-rs/cli/src/marketplace_cmd.rs b/codex-rs/cli/src/marketplace_cmd.rs index a29f9ba76f..d8756c263b 100644 --- a/codex-rs/cli/src/marketplace_cmd.rs +++ b/codex-rs/cli/src/marketplace_cmd.rs @@ -4,15 +4,16 @@ use anyhow::bail; use clap::Parser; use codex_core::config::Config; use codex_core::config::find_codex_home; -use codex_core::plugins::MarketplaceAddRequest; -use codex_core::plugins::MarketplaceRemoveRequest; use codex_core::plugins::PluginMarketplaceUpgradeOutcome; use codex_core::plugins::PluginsManager; -use codex_core::plugins::add_marketplace; -use codex_core::plugins::remove_marketplace; +use codex_core_plugins::marketplace_add::MarketplaceAddRequest; +use codex_core_plugins::marketplace_add::add_marketplace; +use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest; +use codex_core_plugins::marketplace_remove::remove_marketplace; use codex_utils_cli::CliConfigOverrides; #[derive(Debug, Parser)] +#[command(bin_name = "codex plugin marketplace")] pub struct MarketplaceCli { #[clap(flatten)] pub config_overrides: CliConfigOverrides, @@ -29,6 +30,7 @@ enum MarketplaceSubcommand { } #[derive(Debug, Parser)] +#[command(bin_name = "codex plugin marketplace add")] struct AddMarketplaceArgs { /// Marketplace source. Supports owner/repo[@ref], HTTP(S) Git URLs, SSH URLs, /// or local marketplace root directories. @@ -46,11 +48,13 @@ struct AddMarketplaceArgs { } #[derive(Debug, Parser)] +#[command(bin_name = "codex plugin marketplace upgrade")] struct UpgradeMarketplaceArgs { marketplace_name: Option, } #[derive(Debug, Parser)] +#[command(bin_name = "codex plugin marketplace remove")] struct RemoveMarketplaceArgs { /// Configured marketplace name to remove. marketplace_name: String, diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index d413f72ddc..c5b4751322 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -486,8 +486,12 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> let mut entries: Vec<_> = mcp_servers.iter().collect(); entries.sort_by(|(a, _), (b, _)| a.cmp(b)); - let auth_statuses = - compute_auth_statuses(mcp_servers.iter(), config.mcp_oauth_credentials_store_mode).await; + let auth_statuses = compute_auth_statuses( + mcp_servers.iter(), + config.mcp_oauth_credentials_store_mode, + /*auth*/ None, + ) + .await; if list_args.json { let json_entries: Vec<_> = entries diff --git a/codex-rs/cli/src/responses_cmd.rs b/codex-rs/cli/src/responses_cmd.rs deleted file mode 100644 index 9cd27638d3..0000000000 --- a/codex-rs/cli/src/responses_cmd.rs +++ /dev/null @@ -1,243 +0,0 @@ -use clap::Parser; -use codex_core::config::Config; -use codex_model_provider::create_model_provider; -use codex_utils_cli::CliConfigOverrides; -use serde_json::json; -use tokio::io::AsyncReadExt; - -#[derive(Debug, Parser)] -pub(crate) struct ResponsesCommand {} - -pub(crate) async fn run_responses_command( - root_config_overrides: CliConfigOverrides, -) -> anyhow::Result<()> { - let mut payload_text = String::new(); - tokio::io::stdin().read_to_string(&mut payload_text).await?; - if payload_text.trim().is_empty() { - anyhow::bail!("expected Responses API JSON payload on stdin"); - } - - let payload: serde_json::Value = serde_json::from_str(&payload_text) - .map_err(|err| anyhow::anyhow!("failed to parse Responses API JSON payload: {err}"))?; - if payload.get("stream").and_then(serde_json::Value::as_bool) != Some(true) { - anyhow::bail!("codex responses expects a streaming payload with `\"stream\": true`"); - } - - let cli_overrides = root_config_overrides - .parse_overrides() - .map_err(anyhow::Error::msg)?; - let config = Config::load_with_cli_overrides(cli_overrides).await?; - let base_auth_manager = codex_login::AuthManager::shared_from_config( - &config, /*enable_codex_api_key_env*/ true, - ); - let model_provider = create_model_provider(config.model_provider, Some(base_auth_manager)); - let api_provider = model_provider.api_provider().await?; - let api_auth = model_provider.api_auth().await?; - let client = codex_api::ResponsesClient::new( - codex_api::ReqwestTransport::new(codex_login::default_client::build_reqwest_client()), - api_provider, - api_auth, - ); - - let mut stream = client - .stream( - payload, - Default::default(), - codex_api::Compression::None, - /*turn_state*/ None, - ) - .await?; - while let Some(event) = stream.rx_event.recv().await { - let event = event?; - println!("{}", serde_json::to_string(&response_event_to_json(event))?); - } - - Ok(()) -} - -fn response_event_to_json(event: codex_api::ResponseEvent) -> serde_json::Value { - match event { - codex_api::ResponseEvent::Created => { - json!({ "type": "response.created", "response": {} }) - } - codex_api::ResponseEvent::OutputItemDone(item) => { - json!({ "type": "response.output_item.done", "item": item }) - } - codex_api::ResponseEvent::OutputItemAdded(item) => { - json!({ "type": "response.output_item.added", "item": item }) - } - codex_api::ResponseEvent::ServerModel(model) => { - json!({ "type": "response.server_model", "model": model }) - } - codex_api::ResponseEvent::ServerReasoningIncluded(included) => { - json!({ "type": "response.server_reasoning_included", "included": included }) - } - codex_api::ResponseEvent::Completed { - response_id, - token_usage, - } => { - let response = match token_usage { - Some(token_usage) => json!({ - "id": response_id, - "usage": { - "input_tokens": token_usage.input_tokens, - "input_tokens_details": { - "cached_tokens": token_usage.cached_input_tokens, - }, - "output_tokens": token_usage.output_tokens, - "output_tokens_details": { - "reasoning_tokens": token_usage.reasoning_output_tokens, - }, - "total_tokens": token_usage.total_tokens, - }, - }), - None => json!({ "id": response_id }), - }; - json!({ "type": "response.completed", "response": response }) - } - codex_api::ResponseEvent::OutputTextDelta(delta) => { - json!({ "type": "response.output_text.delta", "delta": delta }) - } - codex_api::ResponseEvent::ToolCallInputDelta { - item_id, - call_id, - delta, - } => { - json!({ - "type": "response.tool_call_input.delta", - "item_id": item_id, - "call_id": call_id, - "delta": delta, - }) - } - codex_api::ResponseEvent::ReasoningSummaryDelta { - delta, - summary_index, - } => json!({ - "type": "response.reasoning_summary_text.delta", - "delta": delta, - "summary_index": summary_index, - }), - codex_api::ResponseEvent::ReasoningContentDelta { - delta, - content_index, - } => json!({ - "type": "response.reasoning_text.delta", - "delta": delta, - "content_index": content_index, - }), - codex_api::ResponseEvent::ReasoningSummaryPartAdded { summary_index } => { - json!({ - "type": "response.reasoning_summary_part.added", - "summary_index": summary_index, - }) - } - codex_api::ResponseEvent::RateLimits(rate_limits) => { - json!({ "type": "response.rate_limits", "rate_limits": rate_limits }) - } - codex_api::ResponseEvent::ModelsEtag(etag) => { - json!({ "type": "response.models_etag", "etag": etag }) - } - } -} - -#[cfg(test)] -mod tests { - use super::response_event_to_json; - use codex_protocol::protocol::TokenUsage; - use pretty_assertions::assert_eq; - use serde_json::json; - - #[test] - fn response_events_keep_replayable_response_envelopes() { - let created = response_event_to_json(codex_api::ResponseEvent::Created); - assert_eq!(created, json!({"type": "response.created", "response": {}})); - - let completed = response_event_to_json(codex_api::ResponseEvent::Completed { - response_id: "resp-1".to_string(), - token_usage: Some(TokenUsage { - input_tokens: 10, - cached_input_tokens: 4, - output_tokens: 7, - reasoning_output_tokens: 3, - total_tokens: 17, - }), - }); - assert_eq!( - completed, - json!({ - "type": "response.completed", - "response": { - "id": "resp-1", - "usage": { - "input_tokens": 10, - "input_tokens_details": { - "cached_tokens": 4, - }, - "output_tokens": 7, - "output_tokens_details": { - "reasoning_tokens": 3, - }, - "total_tokens": 17, - }, - }, - }) - ); - - let completed_without_usage = response_event_to_json(codex_api::ResponseEvent::Completed { - response_id: "resp-2".to_string(), - token_usage: None, - }); - assert_eq!( - completed_without_usage, - json!({"type": "response.completed", "response": {"id": "resp-2"}}) - ); - } - - #[test] - fn reasoning_deltas_use_responses_event_names() { - let summary = response_event_to_json(codex_api::ResponseEvent::ReasoningSummaryDelta { - delta: "plan".to_string(), - summary_index: 1, - }); - assert_eq!( - summary, - json!({ - "type": "response.reasoning_summary_text.delta", - "delta": "plan", - "summary_index": 1, - }) - ); - - let content = response_event_to_json(codex_api::ResponseEvent::ReasoningContentDelta { - delta: "detail".to_string(), - content_index: 2, - }); - assert_eq!( - content, - json!({ - "type": "response.reasoning_text.delta", - "delta": "detail", - "content_index": 2, - }) - ); - } - - #[test] - fn tool_call_input_delta_uses_responses_event_name() { - let delta = response_event_to_json(codex_api::ResponseEvent::ToolCallInputDelta { - item_id: "item-1".to_string(), - call_id: Some("call-1".to_string()), - delta: "patch".to_string(), - }); - assert_eq!( - delta, - json!({ - "type": "response.tool_call_input.delta", - "item_id": "item-1", - "call_id": "call-1", - "delta": "patch", - }) - ); - } -} diff --git a/codex-rs/cli/tests/login.rs b/codex-rs/cli/tests/login.rs new file mode 100644 index 0000000000..8f26cd51d4 --- /dev/null +++ b/codex-rs/cli/tests/login.rs @@ -0,0 +1,74 @@ +use std::path::Path; + +use anyhow::Result; +use predicates::str::contains; +use pretty_assertions::assert_eq; +use serde_json::Value; +use tempfile::TempDir; + +const FAKE_AGENT_IDENTITY_JWT: &str = "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJhZ2VudF9ydW50aW1lX2lkIjoiYWdlbnQtcnVudGltZS1pZCIsImFnZW50X3ByaXZhdGVfa2V5IjoicHJpdmF0ZS1rZXkiLCJhY2NvdW50X2lkIjoiYWNjb3VudC0xMjMiLCJjaGF0Z3B0X3VzZXJfaWQiOiJ1c2VyLWlkIiwiZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIiwicGxhbl90eXBlIjoicHJvIiwiY2hhdGdwdF9hY2NvdW50X2lzX2ZlZHJhbXAiOmZhbHNlfQ.c2ln"; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +fn write_file_auth_config(codex_home: &Path) -> Result<()> { + std::fs::write( + codex_home.join("config.toml"), + "cli_auth_credentials_store = \"file\"\n", + )?; + Ok(()) +} + +fn read_auth_json(codex_home: &Path) -> Result { + let auth_json = std::fs::read_to_string(codex_home.join("auth.json"))?; + Ok(serde_json::from_str(&auth_json)?) +} + +#[test] +fn login_with_api_key_reads_stdin_and_writes_auth_json() -> Result<()> { + let codex_home = TempDir::new()?; + write_file_auth_config(codex_home.path())?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args([ + "-c", + "forced_login_method=\"api\"", + "login", + "--with-api-key", + ]) + .write_stdin("sk-test\n") + .assert() + .success() + .stderr(contains("Successfully logged in")); + + let auth = read_auth_json(codex_home.path())?; + assert_eq!(auth["OPENAI_API_KEY"], "sk-test"); + assert!(auth.get("tokens").is_none()); + assert!(auth.get("agent_identity").is_none()); + + Ok(()) +} + +#[test] +fn login_with_agent_identity_reads_stdin_and_writes_auth_json() -> Result<()> { + let codex_home = TempDir::new()?; + write_file_auth_config(codex_home.path())?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args(["login", "--with-agent-identity"]) + .write_stdin(format!("{FAKE_AGENT_IDENTITY_JWT}\n")) + .assert() + .success() + .stderr(contains("Successfully logged in")); + + let auth = read_auth_json(codex_home.path())?; + assert_eq!(auth["auth_mode"], "agentIdentity"); + assert_eq!(auth["agent_identity"], FAKE_AGENT_IDENTITY_JWT); + assert!(auth["OPENAI_API_KEY"].is_null()); + assert!(auth.get("tokens").is_none()); + + Ok(()) +} diff --git a/codex-rs/cli/tests/marketplace_add.rs b/codex-rs/cli/tests/marketplace_add.rs index e4256f52b2..5ab18e24c4 100644 --- a/codex-rs/cli/tests/marketplace_add.rs +++ b/codex-rs/cli/tests/marketplace_add.rs @@ -1,6 +1,6 @@ use anyhow::Result; use codex_config::CONFIG_TOML_FILE; -use codex_core::plugins::marketplace_install_root; +use codex_core_plugins::installed_marketplaces::marketplace_install_root; use predicates::str::contains; use pretty_assertions::assert_eq; use std::path::Path; diff --git a/codex-rs/cli/tests/marketplace_remove.rs b/codex-rs/cli/tests/marketplace_remove.rs index 06e213bae6..5c8c7a1f91 100644 --- a/codex-rs/cli/tests/marketplace_remove.rs +++ b/codex-rs/cli/tests/marketplace_remove.rs @@ -1,7 +1,7 @@ use anyhow::Result; use codex_config::MarketplaceConfigUpdate; use codex_config::record_user_marketplace; -use codex_core::plugins::marketplace_install_root; +use codex_core_plugins::installed_marketplaces::marketplace_install_root; use predicates::str::contains; use std::path::Path; use tempfile::TempDir; diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index 914958d2c7..d38fbb0846 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -15,11 +15,11 @@ use chrono::DateTime; use chrono::Duration as ChronoDuration; use chrono::Utc; use codex_backend_client::Client as BackendClient; +use codex_config::CloudRequirementsLoadError; +use codex_config::CloudRequirementsLoadErrorCode; +use codex_config::CloudRequirementsLoader; +use codex_config::ConfigRequirementsToml; use codex_config::types::AuthCredentialsStoreMode; -use codex_core::config_loader::CloudRequirementsLoadError; -use codex_core::config_loader::CloudRequirementsLoadErrorCode; -use codex_core::config_loader::CloudRequirementsLoader; -use codex_core::config_loader::ConfigRequirementsToml; use codex_core::util::backoff; use codex_login::AuthManager; use codex_login::CodexAuth; @@ -50,9 +50,16 @@ const CLOUD_REQUIREMENTS_CACHE_TTL: Duration = Duration::from_secs(30 * 60); const CLOUD_REQUIREMENTS_FETCH_ATTEMPT_METRIC: &str = "codex.cloud_requirements.fetch_attempt"; const CLOUD_REQUIREMENTS_FETCH_FINAL_METRIC: &str = "codex.cloud_requirements.fetch_final"; const CLOUD_REQUIREMENTS_LOAD_METRIC: &str = "codex.cloud_requirements.load"; -const CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE: &str = "failed to load your workspace-managed config"; -const CLOUD_REQUIREMENTS_PARSE_FAILED_MESSAGE: &str = "Your workspace-managed config is invalid and could not be parsed. Please contact your workspace admin."; -const CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE: &str = "Your authentication session could not be refreshed automatically. Please log out and sign in again."; +const CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE: &str = + "Failed to load cloud requirements (workspace-managed policies)."; +const CLOUD_REQUIREMENTS_PARSE_FAILED_MESSAGE: &str = concat!( + "Cloud requirements (workspace-managed policies) are invalid and could not be parsed. ", + "Please contact your workspace admin." +); +const CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE: &str = concat!( + "Your authentication session could not be refreshed automatically. ", + "Please log out and sign in again." +); const CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY: &[u8] = b"codex-cloud-requirements-cache-v3-064f8542-75b4-494c-a294-97d3ce597271"; const CLOUD_REQUIREMENTS_CACHE_READ_HMAC_KEYS: &[&[u8]] = @@ -169,13 +176,7 @@ fn verify_cache_signature(payload_bytes: &[u8], signature: &str) -> bool { } fn auth_identity(auth: &CodexAuth) -> (Option, Option) { - let token_data = auth.get_token_data().ok(); - let chatgpt_user_id = token_data - .as_ref() - .and_then(|token_data| token_data.id_token.chatgpt_user_id.as_deref()) - .map(str::to_owned); - let account_id = auth.get_account_id(); - (chatgpt_user_id, account_id) + (auth.get_chatgpt_user_id(), auth.get_account_id()) } fn cache_payload_bytes(payload: &CloudRequirementsCacheSignedPayload) -> Option> { @@ -328,10 +329,15 @@ impl CloudRequirementsService { let Some(auth) = self.auth_manager.auth().await else { return Ok(None); }; + if matches!(auth, CodexAuth::AgentIdentity(_)) { + // AgentIdentity does not carry a human bearer token, and identity-edge + // only allowlists task-scoped AgentAssertion calls for the Codex runtime. + return Ok(None); + } let Some(plan_type) = auth.account_plan_type() else { return Ok(None); }; - if !auth.is_chatgpt_auth() + if !auth.uses_codex_backend() || !(plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise)) { return Ok(None); @@ -551,7 +557,7 @@ impl CloudRequirementsService { let Some(plan_type) = auth.account_plan_type() else { return false; }; - if !auth.is_chatgpt_auth() + if !auth.uses_codex_backend() || !(plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise)) { return false; @@ -721,7 +727,7 @@ pub fn cloud_requirements_loader( }) } -pub fn cloud_requirements_loader_for_storage( +pub async fn cloud_requirements_loader_for_storage( codex_home: PathBuf, enable_codex_api_key_env: bool, credentials_store_mode: AuthCredentialsStoreMode, @@ -731,7 +737,9 @@ pub fn cloud_requirements_loader_for_storage( codex_home.clone(), enable_codex_api_key_env, credentials_store_mode, - ); + Some(chatgpt_base_url.clone()), + ) + .await; cloud_requirements_loader(auth_manager, chatgpt_base_url, codex_home) } @@ -846,7 +854,7 @@ mod tests { Ok(()) } - fn auth_manager_with_api_key() -> Arc { + async fn auth_manager_with_api_key() -> Arc { let tmp = tempdir().expect("tempdir"); let auth_json = json!({ "OPENAI_API_KEY": "sk-test-key", @@ -854,14 +862,18 @@ mod tests { "last_refresh": null, }); write_auth_json(tmp.path(), auth_json).expect("write auth"); - Arc::new(AuthManager::new( - tmp.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - )) + Arc::new( + AuthManager::new( + tmp.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ) } - fn auth_manager_with_plan_and_identity( + async fn auth_manager_with_plan_and_identity( plan_type: &str, chatgpt_user_id: Option<&str>, account_id: Option<&str>, @@ -878,11 +890,15 @@ mod tests { ), ) .expect("write auth"); - Arc::new(AuthManager::new( - tmp.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - )) + Arc::new( + AuthManager::new( + tmp.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ) } fn chatgpt_auth_json( @@ -966,7 +982,7 @@ mod tests { manager: Arc, } - fn managed_auth_context( + async fn managed_auth_context( plan_type: &str, chatgpt_user_id: Option<&str>, account_id: Option<&str>, @@ -986,17 +1002,22 @@ mod tests { ) .expect("write auth"); ManagedAuthContext { - manager: Arc::new(AuthManager::new( - home.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - )), + manager: Arc::new( + AuthManager::new( + home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ), _home: home, } } - fn auth_manager_with_plan(plan_type: &str) -> Arc { + async fn auth_manager_with_plan(plan_type: &str) -> Arc { auth_manager_with_plan_and_identity(plan_type, Some("user-12345"), Some("account-12345")) + .await } fn parse_for_fetch(contents: Option<&str>) -> Option { @@ -1108,7 +1129,7 @@ mod tests { #[tokio::test] async fn fetch_cloud_requirements_skips_non_chatgpt_auth() { - let auth_manager = auth_manager_with_api_key(); + let auth_manager = auth_manager_with_api_key().await; let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( auth_manager, @@ -1124,7 +1145,7 @@ mod tests { async fn fetch_cloud_requirements_skips_non_business_or_enterprise_plan() { let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - auth_manager_with_plan("pro"), + auth_manager_with_plan("pro").await, Arc::new(StaticFetcher { contents: None }), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, @@ -1137,7 +1158,7 @@ mod tests { async fn fetch_cloud_requirements_skips_team_like_usage_based_plan() { let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - auth_manager_with_plan("self_serve_business_usage_based"), + auth_manager_with_plan("self_serve_business_usage_based").await, Arc::new(StaticFetcher { contents: Some("allowed_approval_policies = [\"never\"]".to_string()), }), @@ -1151,7 +1172,7 @@ mod tests { async fn fetch_cloud_requirements_allows_business_plan() { let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - auth_manager_with_plan("business"), + auth_manager_with_plan("business").await, Arc::new(StaticFetcher { contents: Some("allowed_approval_policies = [\"never\"]".to_string()), }), @@ -1168,6 +1189,7 @@ mod tests { allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -1182,7 +1204,7 @@ mod tests { async fn fetch_cloud_requirements_allows_business_like_usage_based_plan() { let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise_cbp_usage_based"), + auth_manager_with_plan("enterprise_cbp_usage_based").await, Arc::new(StaticFetcher { contents: Some("allowed_approval_policies = [\"never\"]".to_string()), }), @@ -1199,6 +1221,7 @@ mod tests { allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -1213,7 +1236,7 @@ mod tests { async fn fetch_cloud_requirements_allows_hc_plan_as_enterprise() { let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - auth_manager_with_plan("hc"), + auth_manager_with_plan("hc").await, Arc::new(StaticFetcher { contents: Some("allowed_approval_policies = [\"never\"]".to_string()), }), @@ -1230,6 +1253,7 @@ mod tests { allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -1278,6 +1302,7 @@ mod tests { allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -1300,10 +1325,10 @@ enabled = false assert_eq!( result, Some(ConfigRequirementsToml { - apps: Some(codex_core::config_loader::AppsRequirementsToml { + apps: Some(codex_config::AppsRequirementsToml { apps: BTreeMap::from([( "connector_5f3c8c41a1e54ad7a76272c89e2554fa".to_string(), - codex_core::config_loader::AppRequirementToml { + codex_config::AppRequirementToml { enabled: Some(false), }, )]), @@ -1315,7 +1340,7 @@ enabled = false #[tokio::test(start_paused = true)] async fn fetch_cloud_requirements_times_out() { - let auth_manager = auth_manager_with_plan("enterprise"); + let auth_manager = auth_manager_with_plan("enterprise").await; let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( auth_manager, @@ -1342,7 +1367,7 @@ enabled = false ])); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - auth_manager_with_plan("business"), + auth_manager_with_plan("business").await, fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, @@ -1362,6 +1387,7 @@ enabled = false allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -1390,11 +1416,15 @@ enabled = false ), ) .expect("write initial auth"); - let auth_manager = Arc::new(AuthManager::new( - auth_home.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - )); + let auth_manager = Arc::new( + AuthManager::new( + auth_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); write_auth_json( auth_home.path(), @@ -1436,6 +1466,7 @@ enabled = false allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -1462,11 +1493,15 @@ enabled = false ), ) .expect("write initial auth"); - let auth_manager = Arc::new(AuthManager::new( - auth_home.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - )); + let auth_manager = Arc::new( + AuthManager::new( + auth_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); write_auth_json( auth_home.path(), @@ -1508,6 +1543,7 @@ enabled = false allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -1540,7 +1576,8 @@ enabled = false Some("account-12345"), "stale-access-token", "test-refresh-token", - ); + ) + .await; write_auth_json( auth._home.path(), chatgpt_auth_json( @@ -1592,11 +1629,15 @@ enabled = false ), ) .expect("write auth"); - let auth_manager = Arc::new(AuthManager::new( - auth_home.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - )); + let auth_manager = Arc::new( + AuthManager::new( + auth_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); let fetcher = Arc::new(UnauthorizedFetcher { message: @@ -1633,7 +1674,7 @@ enabled = false ])); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - auth_manager_with_plan("business"), + auth_manager_with_plan("business").await, fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, @@ -1658,7 +1699,7 @@ enabled = false ))])); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - auth_manager_with_plan("business"), + auth_manager_with_plan("business").await, fetcher, codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, @@ -1680,7 +1721,7 @@ enabled = false async fn fetch_cloud_requirements_uses_cache_when_valid() { let codex_home = tempdir().expect("tempdir"); let prime_service = CloudRequirementsService::new( - auth_manager_with_plan("business"), + auth_manager_with_plan("business").await, Arc::new(StaticFetcher { contents: Some("allowed_approval_policies = [\"never\"]".to_string()), }), @@ -1691,7 +1732,7 @@ enabled = false let fetcher = Arc::new(SequenceFetcher::new(vec![Err(request_error())])); let service = CloudRequirementsService::new( - auth_manager_with_plan("business"), + auth_manager_with_plan("business").await, fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, @@ -1707,6 +1748,7 @@ enabled = false allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -1726,7 +1768,8 @@ enabled = false "business", /*chatgpt_user_id*/ None, Some("account-12345"), - ), + ) + .await, Arc::new(StaticFetcher { contents: Some("allowed_approval_policies = [\"never\"]".to_string()), }), @@ -1744,6 +1787,7 @@ enabled = false allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -1768,7 +1812,7 @@ enabled = false async fn fetch_cloud_requirements_does_not_use_cache_when_auth_identity_is_incomplete() { let codex_home = tempdir().expect("tempdir"); let prime_service = CloudRequirementsService::new( - auth_manager_with_plan("business"), + auth_manager_with_plan("business").await, Arc::new(StaticFetcher { contents: Some("allowed_approval_policies = [\"never\"]".to_string()), }), @@ -1785,7 +1829,8 @@ enabled = false "business", /*chatgpt_user_id*/ None, Some("account-12345"), - ), + ) + .await, fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, @@ -1801,6 +1846,7 @@ enabled = false allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -1820,7 +1866,8 @@ enabled = false "business", Some("user-12345"), Some("account-12345"), - ), + ) + .await, Arc::new(StaticFetcher { contents: Some("allowed_approval_policies = [\"never\"]".to_string()), }), @@ -1837,7 +1884,8 @@ enabled = false "business", Some("user-99999"), Some("account-12345"), - ), + ) + .await, fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, @@ -1853,6 +1901,7 @@ enabled = false allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -1868,7 +1917,7 @@ enabled = false async fn fetch_cloud_requirements_ignores_tampered_cache() { let codex_home = tempdir().expect("tempdir"); let prime_service = CloudRequirementsService::new( - auth_manager_with_plan("business"), + auth_manager_with_plan("business").await, Arc::new(StaticFetcher { contents: Some("allowed_approval_policies = [\"never\"]".to_string()), }), @@ -1893,7 +1942,7 @@ enabled = false "allowed_approval_policies = [\"never\"]".to_string(), ))])); let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise"), + auth_manager_with_plan("enterprise").await, fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, @@ -1909,6 +1958,7 @@ enabled = false allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -1950,7 +2000,7 @@ enabled = false "allowed_approval_policies = [\"never\"]".to_string(), ))])); let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise"), + auth_manager_with_plan("enterprise").await, fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, @@ -1966,6 +2016,7 @@ enabled = false allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -1981,7 +2032,7 @@ enabled = false async fn fetch_cloud_requirements_writes_signed_cache() { let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - auth_manager_with_plan("business"), + auth_manager_with_plan("business").await, Arc::new(StaticFetcher { contents: Some("allowed_approval_policies = [\"never\"]".to_string()), }), @@ -2023,6 +2074,7 @@ enabled = false allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -2043,7 +2095,7 @@ enabled = false let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(None), Err(request_error())])); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise"), + auth_manager_with_plan("enterprise").await, fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, @@ -2061,7 +2113,7 @@ enabled = false ])); let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - auth_manager_with_plan("enterprise"), + auth_manager_with_plan("enterprise").await, fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, @@ -2076,10 +2128,7 @@ enabled = false .await .expect("cloud requirements task") .expect_err("cloud requirements retry exhaustion should fail closed"); - assert_eq!( - err.to_string(), - "failed to load your workspace-managed config" - ); + assert_eq!(err.to_string(), CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE); assert_eq!(err.code(), CloudRequirementsLoadErrorCode::RequestFailed); assert_eq!( fetcher.request_count.load(Ordering::SeqCst), @@ -2097,7 +2146,7 @@ enabled = false )), ])); let service = CloudRequirementsService::new( - auth_manager_with_plan("business"), + auth_manager_with_plan("business").await, fetcher, codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, @@ -2113,6 +2162,7 @@ enabled = false allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -2142,6 +2192,7 @@ enabled = false allowed_web_search_modes: None, guardian_policy_config: None, feature_requirements: None, + hooks: None, mcp_servers: None, apps: None, rules: None, diff --git a/codex-rs/cloud-tasks-client/Cargo.toml b/codex-rs/cloud-tasks-client/Cargo.toml index cdfcba47b8..929c3e3136 100644 --- a/codex-rs/cloud-tasks-client/Cargo.toml +++ b/codex-rs/cloud-tasks-client/Cargo.toml @@ -15,6 +15,7 @@ workspace = true anyhow = { workspace = true } async-trait = { workspace = true } chrono = { workspace = true, features = ["serde"] } +codex-api = { workspace = true } codex-backend-client = { workspace = true } codex-git-utils = { workspace = true } serde = { version = "1", features = ["derive"] } diff --git a/codex-rs/cloud-tasks-client/src/http.rs b/codex-rs/cloud-tasks-client/src/http.rs index 4ea0980227..46fed812ba 100644 --- a/codex-rs/cloud-tasks-client/src/http.rs +++ b/codex-rs/cloud-tasks-client/src/http.rs @@ -14,6 +14,7 @@ use crate::api::TaskText; use chrono::DateTime; use chrono::Utc; +use codex_api::SharedAuthProvider; use codex_backend_client as backend; use codex_backend_client::CodeTaskDetailsResponseExt; use codex_git_utils::ApplyGitRequest; @@ -32,13 +33,13 @@ impl HttpClient { Ok(Self { base_url, backend }) } - pub fn with_bearer_token(mut self, token: impl Into) -> Self { - self.backend = self.backend.clone().with_bearer_token(token); + pub fn with_user_agent(mut self, ua: impl Into) -> Self { + self.backend = self.backend.clone().with_user_agent(ua); self } - pub fn with_user_agent(mut self, ua: impl Into) -> Self { - self.backend = self.backend.clone().with_user_agent(ua); + pub fn with_auth_provider(mut self, auth: SharedAuthProvider) -> Self { + self.backend = self.backend.clone().with_auth_provider(auth); self } diff --git a/codex-rs/cloud-tasks/Cargo.toml b/codex-rs/cloud-tasks/Cargo.toml index 30e8b73a8f..6429c1edcd 100644 --- a/codex-rs/cloud-tasks/Cargo.toml +++ b/codex-rs/cloud-tasks/Cargo.toml @@ -13,7 +13,6 @@ workspace = true [dependencies] anyhow = { workspace = true } -base64 = { workspace = true } chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["derive"] } codex-client = { workspace = true } @@ -23,6 +22,7 @@ codex-cloud-tasks-mock-client = { workspace = true } codex-core = { workspace = true } codex-git-utils = { workspace = true } codex-login = { path = "../login" } +codex-model-provider = { workspace = true } codex-tui = { workspace = true } codex-utils-cli = { workspace = true } crossterm = { workspace = true, features = ["event-stream"] } diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index 7006d52b92..e8d6b545b5 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -68,7 +68,7 @@ async fn init_backend(user_agent_suffix: &str) -> anyhow::Result }; append_error_log(format!("startup: base_url={base_url} path_style={style}")); - let auth_manager = util::load_auth_manager().await; + let auth_manager = util::load_auth_manager(Some(base_url.clone())).await; let auth = match auth_manager.as_ref() { Some(manager) => manager.auth().await, None => None, @@ -87,23 +87,17 @@ async fn init_backend(user_agent_suffix: &str) -> anyhow::Result append_error_log(format!("auth: mode=ChatGPT account_id={acc}")); } - let token = match auth.get_token() { - Ok(t) if !t.is_empty() => t, - _ => { - eprintln!( - "Not signed in. Please run 'codex login' to sign in with ChatGPT, then re-run 'codex cloud'." - ); - std::process::exit(1); - } - }; + if !auth.uses_codex_backend() { + eprintln!( + "Not signed in. Please run 'codex login' to sign in with ChatGPT, then re-run 'codex cloud'." + ); + std::process::exit(1); + } - http = http.with_bearer_token(token.clone()); - if let Some(acc) = auth - .get_account_id() - .or_else(|| util::extract_chatgpt_account_id(&token)) - { + let auth_provider = codex_model_provider::auth_provider_from_auth(&auth); + http = http.with_auth_provider(auth_provider); + if let Some(acc) = auth.get_account_id() { append_error_log(format!("auth: set ChatGPT-Account-Id header: {acc}")); - http = http.with_chatgpt_account_id(acc); } Ok(BackendContext { diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index cbaed17bea..9a5056aa66 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -1,4 +1,3 @@ -use base64::Engine as _; use chrono::DateTime; use chrono::Local; use chrono::Utc; @@ -42,38 +41,23 @@ pub fn normalize_base_url(input: &str) -> String { base_url } -/// Extract the ChatGPT account id from a JWT token, when present. -pub fn extract_chatgpt_account_id(token: &str) -> Option { - let mut parts = token.split('.'); - let (_h, payload_b64, _s) = match (parts.next(), parts.next(), parts.next()) { - (Some(h), Some(p), Some(s)) if !h.is_empty() && !p.is_empty() && !s.is_empty() => (h, p, s), - _ => return None, - }; - let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(payload_b64) - .ok()?; - let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?; - v.get("https://api.openai.com/auth") - .and_then(|auth| auth.get("chatgpt_account_id")) - .and_then(|id| id.as_str()) - .map(str::to_string) -} - -pub async fn load_auth_manager() -> Option { +pub async fn load_auth_manager(chatgpt_base_url: Option) -> Option { // TODO: pass in cli overrides once cloud tasks properly support them. let config = Config::load_with_cli_overrides(Vec::new()).await.ok()?; - Some(AuthManager::new( - config.codex_home.to_path_buf(), - /*enable_codex_api_key_env*/ false, - config.cli_auth_credentials_store_mode, - )) + Some( + AuthManager::new( + config.codex_home.to_path_buf(), + /*enable_codex_api_key_env*/ false, + config.cli_auth_credentials_store_mode, + chatgpt_base_url.or(Some(config.chatgpt_base_url)), + ) + .await, + ) } /// Build headers for ChatGPT-backed requests: `User-Agent`, optional `Authorization`, /// and optional `ChatGPT-Account-Id`. pub async fn build_chatgpt_headers() -> HeaderMap { - use reqwest::header::AUTHORIZATION; - use reqwest::header::HeaderName; use reqwest::header::HeaderValue; use reqwest::header::USER_AGENT; @@ -84,23 +68,11 @@ pub async fn build_chatgpt_headers() -> HeaderMap { USER_AGENT, HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")), ); - if let Some(am) = load_auth_manager().await + if let Some(am) = load_auth_manager(/*chatgpt_base_url*/ None).await && let Some(auth) = am.auth().await - && let Ok(tok) = auth.get_token() - && !tok.is_empty() + && auth.uses_codex_backend() { - let v = format!("Bearer {tok}"); - if let Ok(hv) = HeaderValue::from_str(&v) { - headers.insert(AUTHORIZATION, hv); - } - if let Some(acc) = auth - .get_account_id() - .or_else(|| extract_chatgpt_account_id(&tok)) - && let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id") - && let Ok(hv) = HeaderValue::from_str(&acc) - { - headers.insert(name, hv); - } + headers.extend(codex_model_provider::auth_provider_from_auth(&auth).to_auth_headers()); } headers } diff --git a/codex-rs/code-mode/src/lib.rs b/codex-rs/code-mode/src/lib.rs index bb27d99960..bf0ce699cc 100644 --- a/codex-rs/code-mode/src/lib.rs +++ b/codex-rs/code-mode/src/lib.rs @@ -18,11 +18,13 @@ pub use description::render_json_schema_to_typescript; pub use response::DEFAULT_IMAGE_DETAIL; pub use response::FunctionCallOutputContentItem; pub use response::ImageDetail; +pub use runtime::CodeModeNestedToolCall; pub use runtime::DEFAULT_EXEC_YIELD_TIME_MS; pub use runtime::DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL; pub use runtime::DEFAULT_WAIT_YIELD_TIME_MS; pub use runtime::ExecuteRequest; pub use runtime::RuntimeResponse; +pub use runtime::WaitOutcome; pub use runtime::WaitRequest; pub use service::CodeModeService; pub use service::CodeModeTurnHost; diff --git a/codex-rs/code-mode/src/runtime/mod.rs b/codex-rs/code-mode/src/runtime/mod.rs index 0f50edd329..200a47c989 100644 --- a/codex-rs/code-mode/src/runtime/mod.rs +++ b/codex-rs/code-mode/src/runtime/mod.rs @@ -10,6 +10,7 @@ use std::sync::mpsc as std_mpsc; use std::thread; use codex_protocol::ToolName; +use serde::Serialize; use serde_json::Value as JsonValue; use tokio::sync::mpsc; @@ -25,6 +26,11 @@ const EXIT_SENTINEL: &str = "__codex_code_mode_exit__"; #[derive(Clone, Debug)] pub struct ExecuteRequest { + /// Runtime cell id for this execution. + /// + /// Callers allocate this before execution so tracing, waits, and nested tool + /// calls can refer to the cell as soon as JavaScript starts. + pub cell_id: String, pub tool_call_id: String, pub enabled_tools: Vec, pub source: String, @@ -40,7 +46,30 @@ pub struct WaitRequest { pub terminate: bool, } +/// Result of waiting on a code-mode cell. +/// +/// The wrapped `RuntimeResponse` is the model-facing wait result. The enum +/// variant carries the extra lifecycle provenance that `RuntimeResponse` cannot: +/// a failed real cell and a missing-cell wait both use +/// `RuntimeResponse::Result { error_text: Some(..), .. }`, but only the former +/// should be treated as a code-cell lifecycle event. #[derive(Debug, PartialEq)] +pub enum WaitOutcome { + /// The requested code cell was live when the wait command was accepted. + LiveCell(RuntimeResponse), + /// The requested code cell was not live. + MissingCell(RuntimeResponse), +} + +impl From for RuntimeResponse { + fn from(outcome: WaitOutcome) -> Self { + match outcome { + WaitOutcome::LiveCell(response) | WaitOutcome::MissingCell(response) => response, + } + } +} + +#[derive(Debug, PartialEq, Serialize)] pub enum RuntimeResponse { Yielded { cell_id: String, @@ -58,14 +87,22 @@ pub enum RuntimeResponse { }, } +/// Nested tool request emitted by one code-mode cell. +/// +/// Code mode owns the per-cell runtime id. Hosts should preserve it for +/// provenance/debugging, but should still assign their own runtime tool call id +/// if their tool-call graph requires globally unique ids. +#[derive(Debug)] +pub struct CodeModeNestedToolCall { + pub cell_id: String, + pub runtime_tool_call_id: String, + pub tool_name: ToolName, + pub input: Option, +} + #[derive(Debug)] pub(crate) enum TurnMessage { - ToolCall { - cell_id: String, - id: String, - name: ToolName, - input: Option, - }, + ToolCall(CodeModeNestedToolCall), Notify { cell_id: String, call_id: String, @@ -331,6 +368,7 @@ mod tests { fn execute_request(source: &str) -> ExecuteRequest { ExecuteRequest { + cell_id: "1".to_string(), tool_call_id: "call_1".to_string(), enabled_tools: Vec::new(), source: source.to_string(), diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode/src/service.rs index 4a46d36b41..7326c834e2 100644 --- a/codex-rs/code-mode/src/service.rs +++ b/codex-rs/code-mode/src/service.rs @@ -5,7 +5,6 @@ use std::sync::atomic::Ordering; use std::time::Duration; use async_trait::async_trait; -use codex_protocol::ToolName; use serde_json::Value as JsonValue; use tokio::sync::Mutex; use tokio::sync::mpsc; @@ -14,12 +13,14 @@ use tokio_util::sync::CancellationToken; use tracing::warn; use crate::FunctionCallOutputContentItem; +use crate::runtime::CodeModeNestedToolCall; use crate::runtime::DEFAULT_EXEC_YIELD_TIME_MS; use crate::runtime::ExecuteRequest; use crate::runtime::RuntimeCommand; use crate::runtime::RuntimeEvent; use crate::runtime::RuntimeResponse; use crate::runtime::TurnMessage; +use crate::runtime::WaitOutcome; use crate::runtime::WaitRequest; use crate::runtime::spawn_runtime; @@ -27,8 +28,7 @@ use crate::runtime::spawn_runtime; pub trait CodeModeTurnHost: Send + Sync { async fn invoke_tool( &self, - tool_name: ToolName, - input: Option, + invocation: CodeModeNestedToolCall, cancellation_token: CancellationToken, ) -> Result; @@ -76,24 +76,45 @@ impl CodeModeService { *self.inner.stored_values.lock().await = values; } - pub async fn execute(&self, request: ExecuteRequest) -> Result { - let cell_id = self - .inner + /// Reserves the runtime cell id for a future `execute` request. + /// + /// The runtime can issue nested tool calls before the first `execute` + /// response is returned. Hosts that need a parent trace object for those + /// nested calls should allocate the cell id up front and pass it back on the + /// `ExecuteRequest`. + pub fn allocate_cell_id(&self) -> String { + self.inner .next_cell_id .fetch_add(1, Ordering::Relaxed) - .to_string(); + .to_string() + } + + pub async fn execute(&self, request: ExecuteRequest) -> Result { + let cell_id = request.cell_id.clone(); + let initial_yield_time_ms = request.yield_time_ms.unwrap_or(DEFAULT_EXEC_YIELD_TIME_MS); let (event_tx, event_rx) = mpsc::unbounded_channel(); - let (runtime_tx, runtime_terminate_handle) = spawn_runtime(request.clone(), event_tx)?; let (control_tx, control_rx) = mpsc::unbounded_channel(); let (response_tx, response_rx) = oneshot::channel(); + let (runtime_tx, runtime_terminate_handle) = { + let mut sessions = self.inner.sessions.lock().await; + if sessions.contains_key(&cell_id) { + return Err(format!("exec cell {cell_id} already exists")); + } - self.inner.sessions.lock().await.insert( - cell_id.clone(), - SessionHandle { - control_tx: control_tx.clone(), - runtime_tx: runtime_tx.clone(), - }, - ); + let (runtime_tx, runtime_terminate_handle) = spawn_runtime(request, event_tx)?; + + // Keep the session registry locked through insertion so a + // caller-owned cell id cannot race with another execute and replace + // a live runtime. + sessions.insert( + cell_id.clone(), + SessionHandle { + control_tx, + runtime_tx: runtime_tx.clone(), + }, + ); + (runtime_tx, runtime_terminate_handle) + }; tokio::spawn(run_session_control( Arc::clone(&self.inner), @@ -105,7 +126,7 @@ impl CodeModeService { event_rx, control_rx, response_tx, - request.yield_time_ms.unwrap_or(DEFAULT_EXEC_YIELD_TIME_MS), + initial_yield_time_ms, )); response_rx @@ -113,7 +134,7 @@ impl CodeModeService { .map_err(|_| "exec runtime ended unexpectedly".to_string()) } - pub async fn wait(&self, request: WaitRequest) -> Result { + pub async fn wait(&self, request: WaitRequest) -> Result { let cell_id = request.cell_id.clone(); let handle = self .inner @@ -123,7 +144,7 @@ impl CodeModeService { .get(&request.cell_id) .cloned(); let Some(handle) = handle else { - return Ok(missing_cell_response(cell_id)); + return Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))); }; let (response_tx, response_rx) = oneshot::channel(); let control_message = if request.terminate { @@ -135,11 +156,13 @@ impl CodeModeService { } }; if handle.control_tx.send(control_message).is_err() { - return Ok(missing_cell_response(cell_id)); + return Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))); } match response_rx.await { - Ok(response) => Ok(response), - Err(_) => Ok(missing_cell_response(request.cell_id)), + Ok(response) => Ok(WaitOutcome::LiveCell(response)), + Err(_) => Ok(WaitOutcome::MissingCell(missing_cell_response( + request.cell_id, + ))), } } @@ -169,18 +192,14 @@ impl CodeModeService { ); } } - TurnMessage::ToolCall { - cell_id, - id, - name, - input, - } => { + TurnMessage::ToolCall(invocation) => { let host = Arc::clone(&host); let inner = Arc::clone(&inner); tokio::spawn(async move { - let response = host - .invoke_tool(name, input, CancellationToken::new()) - .await; + let cell_id = invocation.cell_id.clone(); + let runtime_tool_call_id = invocation.runtime_tool_call_id.clone(); + let response = + host.invoke_tool(invocation, CancellationToken::new()).await; let runtime_tx = inner .sessions .lock() @@ -191,8 +210,14 @@ impl CodeModeService { return; }; let command = match response { - Ok(result) => RuntimeCommand::ToolResponse { id, result }, - Err(error_text) => RuntimeCommand::ToolError { id, error_text }, + Ok(result) => RuntimeCommand::ToolResponse { + id: runtime_tool_call_id, + result, + }, + Err(error_text) => RuntimeCommand::ToolError { + id: runtime_tool_call_id, + error_text, + }, }; let _ = runtime_tx.send(command); }); @@ -361,12 +386,16 @@ async fn run_session_control( }).await; } RuntimeEvent::ToolCall { id, name, input } => { - let _ = inner.turn_message_tx.send(TurnMessage::ToolCall { + let tool_call = CodeModeNestedToolCall { cell_id: cell_id.clone(), - id, - name, + runtime_tool_call_id: id, + tool_name: name, input, - }).await; + }; + let _ = inner + .turn_message_tx + .send(TurnMessage::ToolCall(tool_call)) + .await; } RuntimeEvent::Result { stored_values, @@ -479,6 +508,8 @@ mod tests { use super::RuntimeResponse; use super::SessionControlCommand; use super::SessionControlContext; + use super::WaitOutcome; + use super::WaitRequest; use super::run_session_control; use crate::FunctionCallOutputContentItem; use crate::runtime::ExecuteRequest; @@ -487,6 +518,7 @@ mod tests { fn execute_request(source: &str) -> ExecuteRequest { ExecuteRequest { + cell_id: "1".to_string(), tool_call_id: "call_1".to_string(), enabled_tools: Vec::new(), source: source.to_string(), @@ -829,6 +861,30 @@ image({ ); } + #[tokio::test] + async fn wait_reports_missing_cell_separately_from_runtime_results() { + let service = CodeModeService::new(); + + let response = service + .wait(WaitRequest { + cell_id: "missing".to_string(), + yield_time_ms: 1, + terminate: false, + }) + .await + .unwrap(); + + assert_eq!( + response, + WaitOutcome::MissingCell(RuntimeResponse::Result { + cell_id: "missing".to_string(), + content_items: Vec::new(), + stored_values: HashMap::new(), + error_text: Some("exec cell missing not found".to_string()), + }) + ); + } + #[tokio::test] async fn terminate_waits_for_runtime_shutdown_before_responding() { let inner = test_inner(); diff --git a/codex-rs/codex-api/src/api_bridge.rs b/codex-rs/codex-api/src/api_bridge.rs index 13c263cbdb..4f37c2ff9b 100644 --- a/codex-rs/codex-api/src/api_bridge.rs +++ b/codex-rs/codex-api/src/api_bridge.rs @@ -32,6 +32,7 @@ pub fn map_api_error(err: ApiError) -> CodexErr { identity_error_code: None, }), ApiError::InvalidRequest { message } => CodexErr::InvalidRequest(message), + ApiError::CyberPolicy { message } => CodexErr::CyberPolicy { message }, ApiError::Transport(transport) => match transport { TransportError::Http { status, @@ -55,7 +56,19 @@ pub fn map_api_error(err: ApiError) -> CodexErr { } if status == http::StatusCode::BAD_REQUEST { - if body_text + if let Ok(parsed) = serde_json::from_str::(&body_text) + && let Some(error) = parsed.get("error") + && error.get("code").and_then(Value::as_str) + == Some(CYBER_POLICY_ERROR_CODE) + { + let message = error + .get("message") + .and_then(Value::as_str) + .filter(|message| !message.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| CYBER_POLICY_FALLBACK_MESSAGE.to_string()); + CodexErr::CyberPolicy { message } + } else if body_text .contains("The image data you provided does not represent a valid image") { CodexErr::InvalidImageRequest() @@ -125,6 +138,9 @@ const OAI_REQUEST_ID_HEADER: &str = "x-oai-request-id"; const CF_RAY_HEADER: &str = "cf-ray"; const X_OPENAI_AUTHORIZATION_ERROR_HEADER: &str = "x-openai-authorization-error"; const X_ERROR_JSON_HEADER: &str = "x-error-json"; +const CYBER_POLICY_ERROR_CODE: &str = "cyber_policy"; +const CYBER_POLICY_FALLBACK_MESSAGE: &str = + "This request has been flagged for possible cybersecurity risk."; #[cfg(test)] #[path = "api_bridge_tests.rs"] diff --git a/codex-rs/codex-api/src/api_bridge_tests.rs b/codex-rs/codex-api/src/api_bridge_tests.rs index 077691c95c..af7e34a649 100644 --- a/codex-rs/codex-api/src/api_bridge_tests.rs +++ b/codex-rs/codex-api/src/api_bridge_tests.rs @@ -26,6 +26,104 @@ fn map_api_error_maps_server_overloaded_from_503_body() { assert!(matches!(err, CodexErr::ServerOverloaded)); } +#[test] +fn map_api_error_maps_cyber_policy_from_400_body() { + let body = serde_json::json!({ + "error": { + "message": "This request has been flagged for potentially high-risk cyber activity.", + "type": "invalid_request", + "param": null, + "code": "cyber_policy" + } + }) + .to_string(); + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::BAD_REQUEST, + url: Some("http://example.com/v1/responses".to_string()), + headers: None, + body: Some(body), + })); + + let CodexErr::CyberPolicy { message } = err else { + panic!("expected CodexErr::CyberPolicy, got {err:?}"); + }; + assert_eq!( + message, + "This request has been flagged for potentially high-risk cyber activity." + ); +} + +#[test] +fn map_api_error_maps_wrapped_websocket_cyber_policy_from_400_body() { + let body = serde_json::json!({ + "type": "error", + "status": 400, + "error": { + "message": "This websocket request was flagged.", + "type": "invalid_request", + "code": "cyber_policy" + } + }) + .to_string(); + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::BAD_REQUEST, + url: Some("ws://example.com/v1/responses".to_string()), + headers: None, + body: Some(body), + })); + + let CodexErr::CyberPolicy { message } = err else { + panic!("expected CodexErr::CyberPolicy, got {err:?}"); + }; + assert_eq!(message, "This websocket request was flagged."); +} + +#[test] +fn map_api_error_uses_cyber_policy_fallback_for_missing_message() { + let body = serde_json::json!({ + "error": { + "code": "cyber_policy" + } + }) + .to_string(); + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::BAD_REQUEST, + url: Some("http://example.com/v1/responses".to_string()), + headers: None, + body: Some(body), + })); + + let CodexErr::CyberPolicy { message } = err else { + panic!("expected CodexErr::CyberPolicy, got {err:?}"); + }; + assert_eq!( + message, + "This request has been flagged for possible cybersecurity risk." + ); +} + +#[test] +fn map_api_error_keeps_unknown_400_errors_generic() { + let body = serde_json::json!({ + "error": { + "message": "Some other bad request.", + "code": "some_other_policy" + } + }) + .to_string(); + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::BAD_REQUEST, + url: Some("http://example.com/v1/responses".to_string()), + headers: None, + body: Some(body.clone()), + })); + + let CodexErr::InvalidRequest(message) = err else { + panic!("expected CodexErr::InvalidRequest, got {err:?}"); + }; + assert_eq!(message, body); +} + #[test] fn map_api_error_maps_usage_limit_limit_name_header() { let mut headers = HeaderMap::new(); diff --git a/codex-rs/codex-api/src/auth.rs b/codex-rs/codex-api/src/auth.rs index d84028a2ce..41394a2258 100644 --- a/codex-rs/codex-api/src/auth.rs +++ b/codex-rs/codex-api/src/auth.rs @@ -1,13 +1,62 @@ +use async_trait::async_trait; +use codex_client::Request; +use codex_client::TransportError; use http::HeaderMap; use std::sync::Arc; -/// Adds authentication headers to API requests. +/// Error returned while applying authentication to an outbound request. +#[derive(Debug, thiserror::Error)] +pub enum AuthError { + #[error("request auth build error: {0}")] + Build(String), + #[error("transient auth error: {0}")] + Transient(String), +} + +impl From for TransportError { + fn from(error: AuthError) -> Self { + match error { + AuthError::Build(message) => TransportError::Build(message), + AuthError::Transient(message) => TransportError::Network(message), + } + } +} + +/// Applies authentication to API requests. /// -/// Implementations should be cheap and non-blocking; any asynchronous -/// refresh or I/O should be handled by higher layers before requests -/// reach this interface. +/// Header-only providers can implement `add_auth_headers`; providers that sign +/// complete requests can override `apply_auth`. +#[async_trait] pub trait AuthProvider: Send + Sync { + /// Adds any auth headers that are available without request body access. + /// + /// Implementations should be cheap and non-blocking. This method is also + /// used by telemetry and non-HTTP request paths. fn add_auth_headers(&self, headers: &mut HeaderMap); + + /// Returns any auth headers that are available without request body access. + fn to_auth_headers(&self) -> HeaderMap { + let mut headers = HeaderMap::new(); + self.add_auth_headers(&mut headers); + headers + } + + /// Applies auth to a complete outbound request and returns the request to send. + /// + /// The input `request` is moved into this method. Implementations may mutate + /// the owned request, or replace it entirely, before returning. + /// + /// Header-only auth providers can rely on the default implementation. + /// Request-signing providers can override this to inspect the final URL, + /// headers, and body bytes before the transport sends the request. + /// + /// Callers must always use the returned request as authoritative. + /// If this returns [`AuthError`], the request should not be sent. + async fn apply_auth(&self, request: Request) -> Result { + let mut request = request; + self.add_auth_headers(&mut request.headers); + Ok(request) + } } /// Shared auth handle passed through API clients. diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index d088206496..4b150b55f1 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -3,6 +3,7 @@ use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::config_types::Verbosity as VerbosityConfig; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::protocol::ModelVerification; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::W3cTraceContext; @@ -71,6 +72,8 @@ pub enum ResponseEvent { /// Emitted when the server includes `OpenAI-Model` on the stream response. /// This can differ from the requested model when backend safety routing applies. ServerModel(String), + /// Emitted when the server recommends additional account verification. + ModelVerifications(Vec), /// Emitted when `X-Reasoning-Included: true` is present on the response, /// meaning the server already accounted for past reasoning tokens and the /// client should not re-estimate them. @@ -78,6 +81,9 @@ pub enum ResponseEvent { Completed { response_id: String, token_usage: Option, + /// Did the model affirmatively end its turn? Some providers do not set this, + /// so we rely on fallback logic when this is `None`. + end_turn: Option, }, OutputTextDelta(String), ToolCallInputDelta { @@ -262,6 +268,7 @@ pub enum ResponsesWsRequest { pub fn create_text_param_for_request( verbosity: Option, output_schema: &Option, + output_schema_strict: bool, ) -> Option { if verbosity.is_none() && output_schema.is_none() { return None; @@ -271,7 +278,7 @@ pub fn create_text_param_for_request( verbosity: verbosity.map(std::convert::Into::into), format: output_schema.as_ref().map(|schema| TextFormat { r#type: TextFormatType::JsonSchema, - strict: true, + strict: output_schema_strict, schema: schema.clone(), name: "codex_output_schema".to_string(), }), diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index aea9b917fb..364dc1e9b9 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -608,6 +608,7 @@ async fn run_websocket_response_stream( continue; } }; + let model_verifications = event.model_verifications(); if event.kind() == "codex.rate_limits" { if let Some(snapshot) = parse_rate_limit_event(&text) { let _ = tx_event.send(Ok(ResponseEvent::RateLimits(snapshot))).await; @@ -622,6 +623,16 @@ async fn run_websocket_response_stream( .await; last_server_model = Some(model); } + if let Some(verifications) = model_verifications + && tx_event + .send(Ok(ResponseEvent::ModelVerifications(verifications))) + .await + .is_err() + { + return Err(ApiError::Stream( + "response event consumer dropped".to_string(), + )); + } match process_responses_event(event) { Ok(Some(event)) => { let is_completed = matches!(event, ResponseEvent::Completed { .. }); diff --git a/codex-rs/codex-api/src/endpoint/session.rs b/codex-rs/codex-api/src/endpoint/session.rs index 5c9ec31593..132c3abd90 100644 --- a/codex-rs/codex-api/src/endpoint/session.rs +++ b/codex-rs/codex-api/src/endpoint/session.rs @@ -8,6 +8,7 @@ use codex_client::RequestBody; use codex_client::RequestTelemetry; use codex_client::Response; use codex_client::StreamResponse; +use codex_client::TransportError; use http::HeaderMap; use http::Method; use serde_json::Value; @@ -55,7 +56,6 @@ impl EndpointSession { if let Some(body) = body { req.body = Some(RequestBody::Json(body.clone())); } - self.auth.add_auth_headers(&mut req.headers); req } @@ -97,7 +97,14 @@ impl EndpointSession { self.provider.retry.to_policy(), self.request_telemetry.clone(), make_request, - |req| self.transport.execute(req), + |req| { + let auth = self.auth.clone(); + let transport = &self.transport; + async move { + let req = auth.apply_auth(req).await.map_err(TransportError::from)?; + transport.execute(req).await + } + }, ) .await?; @@ -131,7 +138,14 @@ impl EndpointSession { self.provider.retry.to_policy(), self.request_telemetry.clone(), make_request, - |req| self.transport.stream(req), + |req| { + let auth = self.auth.clone(); + let transport = &self.transport; + async move { + let req = auth.apply_auth(req).await.map_err(TransportError::from)?; + transport.stream(req).await + } + }, ) .await?; diff --git a/codex-rs/codex-api/src/error.rs b/codex-rs/codex-api/src/error.rs index c0a1719481..c6cb5fd405 100644 --- a/codex-rs/codex-api/src/error.rs +++ b/codex-rs/codex-api/src/error.rs @@ -27,6 +27,8 @@ pub enum ApiError { RateLimit(String), #[error("invalid request: {message}")] InvalidRequest { message: String }, + #[error("cyber policy: {message}")] + CyberPolicy { message: String }, #[error("server overloaded")] ServerOverloaded, } diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index d92bb5b355..0b8aee266b 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -16,6 +16,7 @@ pub use codex_client::ReqwestTransport; pub use codex_client::TransportError; pub use crate::api_bridge::map_api_error; +pub use crate::auth::AuthError; pub use crate::auth::AuthHeaderTelemetry; pub use crate::auth::AuthProvider; pub use crate::auth::SharedAuthProvider; diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index da7e87461e..fb1742463f 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -7,6 +7,7 @@ use codex_client::ByteStream; use codex_client::StreamResponse; use codex_client::TransportError; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::ModelVerification; use codex_protocol::protocol::TokenUsage; use eventsource_stream::Eventsource; use futures::StreamExt; @@ -27,6 +28,7 @@ use tracing::trace; const X_REASONING_INCLUDED_HEADER: &str = "x-reasoning-included"; const OPENAI_MODEL_HEADER: &str = "openai-model"; +const TRUSTED_ACCESS_FOR_CYBER_VERIFICATION: &str = "trusted_access_for_cyber"; /// Streams SSE events from an on-disk fixture for tests. pub fn stream_from_fixture( @@ -121,6 +123,8 @@ struct ResponseCompleted { id: String, #[serde(default)] usage: Option, + #[serde(default)] + end_turn: Option, } #[derive(Debug, Deserialize)] @@ -165,6 +169,7 @@ pub struct ResponsesStreamEvent { #[serde(rename = "type")] pub(crate) kind: String, headers: Option, + metadata: Option, response: Option, item: Option, item_id: Option, @@ -183,8 +188,7 @@ impl ResponsesStreamEvent { /// /// Precedence: /// 1. `response.headers` for standard Responses stream events. - /// 2. top-level `headers` for websocket metadata events (for example - /// `codex.response.metadata`). + /// 2. top-level `headers` for websocket metadata events. pub fn response_model(&self) -> Option { let response_headers_model = self .response @@ -200,6 +204,17 @@ impl ResponsesStreamEvent { .and_then(header_openai_model_value_from_json), } } + + pub(crate) fn model_verifications(&self) -> Option> { + if self.kind() != "response.metadata" { + return None; + } + + self.metadata + .as_ref() + .and_then(|metadata| metadata.get("openai_verification_recommendation")) + .and_then(model_verifications_from_json_value) + } } fn header_openai_model_value_from_json(value: &Value) -> Option { @@ -214,6 +229,38 @@ fn header_openai_model_value_from_json(value: &Value) -> Option { }) } +fn model_verifications_from_json_value(value: &Value) -> Option> { + let verifications = value + .as_array() + .map(|items| { + let mut verifications = Vec::new(); + for verification in items + .iter() + .filter_map(Value::as_str) + .filter_map(parse_model_verification) + { + if !verifications.contains(&verification) { + verifications.push(verification); + } + } + verifications + }) + .unwrap_or_default(); + + if verifications.is_empty() { + None + } else { + Some(verifications) + } +} + +fn parse_model_verification(value: &str) -> Option { + match value { + TRUSTED_ACCESS_FOR_CYBER_VERIFICATION => Some(ModelVerification::TrustedAccessForCyber), + _ => None, + } +} + fn json_value_as_string(value: &Value) -> Option { match value { Value::String(value) => Some(value.clone()), @@ -296,6 +343,9 @@ pub fn process_responses_event( response_error = ApiError::QuotaExceeded; } else if is_usage_not_included(&error) { response_error = ApiError::UsageNotIncluded; + } else if is_cyber_policy_error(&error) { + let message = cyber_policy_message(error.message); + response_error = ApiError::CyberPolicy { message }; } else if is_invalid_prompt_error(&error) { let message = error .message @@ -334,6 +384,7 @@ pub fn process_responses_event( return Ok(Some(ResponseEvent::Completed { response_id: resp.id, token_usage: resp.usage.map(Into::into), + end_turn: resp.end_turn, })); } Err(err) => { @@ -414,6 +465,7 @@ pub async fn process_sse( continue; } }; + let model_verifications = event.model_verifications(); if let Some(model) = event.response_model() && last_server_model.as_deref() != Some(model.as_str()) @@ -427,6 +479,14 @@ pub async fn process_sse( } last_server_model = Some(model); } + if let Some(verifications) = model_verifications + && tx_event + .send(Ok(ResponseEvent::ModelVerifications(verifications))) + .await + .is_err() + { + return; + } match process_responses_event(event) { Ok(Some(event)) => { @@ -488,11 +548,25 @@ fn is_invalid_prompt_error(error: &Error) -> bool { error.code.as_deref() == Some("invalid_prompt") } +fn is_cyber_policy_error(error: &Error) -> bool { + error.code.as_deref() == Some("cyber_policy") +} + fn is_server_overloaded_error(error: &Error) -> bool { error.code.as_deref() == Some("server_is_overloaded") || error.code.as_deref() == Some("slow_down") } +fn cyber_policy_fallback_message() -> String { + "This request has been flagged for possible cybersecurity risk.".to_string() +} + +fn cyber_policy_message(message: Option) -> String { + message + .filter(|message| !message.trim().is_empty()) + .unwrap_or_else(cyber_policy_fallback_message) +} + fn rate_limit_regex() -> &'static regex_lite::Regex { static RE: std::sync::OnceLock = std::sync::OnceLock::new(); #[expect(clippy::unwrap_used)] @@ -633,9 +707,11 @@ mod tests { Ok(ResponseEvent::Completed { response_id, token_usage, + end_turn, }) => { assert_eq!(response_id, "resp1"); assert!(token_usage.is_none()); + assert!(end_turn.is_none()); } other => panic!("unexpected third event: {other:?}"), } @@ -772,9 +848,11 @@ mod tests { Ok(ResponseEvent::Completed { response_id, token_usage, + end_turn, }) => { assert_eq!(response_id, "resp1"); assert!(token_usage.is_none()); + assert!(end_turn.is_none()); } other => panic!("unexpected event: {other:?}"), } @@ -841,6 +919,45 @@ mod tests { assert_matches!(events[0], Err(ApiError::QuotaExceeded)); } + #[tokio::test] + async fn cyber_policy_error_is_fatal() { + let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_fatal_cyber","object":"response","created_at":1759771626,"status":"failed","background":false,"error":{"code":"cyber_policy","message":"This request was flagged for cyber policy."},"incomplete_details":null}}"#; + + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + + let events = collect_events(&[sse1.as_bytes()]).await; + + assert_eq!(events.len(), 1); + + match &events[0] { + Err(ApiError::CyberPolicy { message }) => { + assert_eq!(message, "This request was flagged for cyber policy."); + } + other => panic!("unexpected event: {other:?}"), + } + } + + #[tokio::test] + async fn cyber_policy_error_uses_fallback_for_empty_message() { + let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_fatal_cyber","object":"response","created_at":1759771626,"status":"failed","background":false,"error":{"code":"cyber_policy","message":" "},"incomplete_details":null}}"#; + + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + + let events = collect_events(&[sse1.as_bytes()]).await; + + assert_eq!(events.len(), 1); + + match &events[0] { + Err(ApiError::CyberPolicy { message }) => { + assert_eq!( + message, + "This request has been flagged for possible cybersecurity risk." + ); + } + other => panic!("unexpected event: {other:?}"), + } + } + #[tokio::test] async fn invalid_prompt_without_type_is_invalid_request() { let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_invalid_prompt_no_type","object":"response","created_at":1759771628,"status":"failed","background":false,"error":{"code":"invalid_prompt","message":"Invalid prompt: we've limited access to this content for safety reasons."},"incomplete_details":null}}"#; @@ -975,6 +1092,43 @@ mod tests { } } + #[tokio::test] + async fn spawn_response_stream_ignores_model_verification_header() { + let mut headers = HeaderMap::new(); + headers.insert( + "openai-verification-recommendation", + HeaderValue::from_static(TRUSTED_ACCESS_FOR_CYBER_VERIFICATION), + ); + let completed = json!({ + "type": "response.completed", + "response": { "id": "resp-1" } + }); + let sse = format!("event: response.completed\ndata: {completed}\n\n"); + let bytes = stream::iter(vec![Ok(Bytes::from(sse))]); + let stream_response = StreamResponse { + status: StatusCode::OK, + headers, + bytes: Box::pin(bytes), + }; + + let mut stream = spawn_response_stream( + stream_response, + idle_timeout(), + /*telemetry*/ None, + /*turn_state*/ None, + ); + let mut events = Vec::new(); + while let Some(event) = stream.rx_event.recv().await { + events.push(event.expect("expected ok event")); + } + + assert!( + !events + .iter() + .any(|event| matches!(event, ResponseEvent::ModelVerifications(_))) + ); + } + #[tokio::test] async fn process_sse_ignores_response_model_field_in_payload() { let events = run_sse(vec![ @@ -1001,7 +1155,8 @@ mod tests { &events[1], ResponseEvent::Completed { response_id, - token_usage: None + token_usage: None, + end_turn: None, } if response_id == "resp-1" ); } @@ -1037,7 +1192,43 @@ mod tests { &events[2], ResponseEvent::Completed { response_id, - token_usage: None + token_usage: None, + end_turn: None, + } if response_id == "resp-1" + ); + } + + #[tokio::test] + async fn process_sse_emits_model_verification_field() { + let events = run_sse(vec![ + json!({ + "type": "response.metadata", + "sequence_number": 1, + "response_id": "resp-1", + "metadata": { + "openai_verification_recommendation": [TRUSTED_ACCESS_FOR_CYBER_VERIFICATION] + } + }), + json!({ + "type": "response.completed", + "response": { + "id": "resp-1" + } + }), + ]) + .await; + + assert_matches!( + &events[0], + ResponseEvent::ModelVerifications(verifications) + if verifications == &vec![ModelVerification::TrustedAccessForCyber] + ); + assert_matches!( + &events[1], + ResponseEvent::Completed { + response_id, + token_usage: None, + end_turn: None, } if response_id == "resp-1" ); } @@ -1045,7 +1236,7 @@ mod tests { #[test] fn responses_stream_event_response_model_reads_top_level_headers() { let ev: ResponsesStreamEvent = serde_json::from_value(json!({ - "type": "codex.response.metadata", + "type": "response.metadata", "headers": { "openai-model": CYBER_RESTRICTED_MODEL_FOR_TESTS, } @@ -1080,6 +1271,53 @@ mod tests { ); } + #[test] + fn responses_stream_event_model_verification_reads_metadata_field() { + let event = json!({ + "type": "response.metadata", + "sequence_number": 1, + "response_id": "resp-1", + "metadata": { + "openai_verification_recommendation": [TRUSTED_ACCESS_FOR_CYBER_VERIFICATION] + } + }); + let event: ResponsesStreamEvent = + serde_json::from_value(event).expect("expected event to deserialize"); + + assert_eq!( + event.model_verifications(), + Some(vec![ModelVerification::TrustedAccessForCyber]) + ); + } + + #[test] + fn responses_stream_event_model_verification_ignores_unknown_field() { + let event = json!({ + "type": "response.metadata", + "metadata": { + "openai_verification_recommendation": ["unknown"] + } + }); + let event: ResponsesStreamEvent = + serde_json::from_value(event).expect("expected event to deserialize"); + + assert_eq!(event.model_verifications(), None); + } + + #[test] + fn responses_stream_event_model_verification_ignores_non_array_field() { + let event = json!({ + "type": "response.metadata", + "metadata": { + "openai_verification_recommendation": TRUSTED_ACCESS_FOR_CYBER_VERIFICATION + } + }); + let event: ResponsesStreamEvent = + serde_json::from_value(event).expect("expected event to deserialize"); + + assert_eq!(event.model_verifications(), None); + } + #[test] fn test_try_parse_retry_after() { let err = Error { diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 3184544aa7..218a99f9b2 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -5,6 +5,8 @@ use std::time::Duration; use anyhow::Result; use async_trait::async_trait; use bytes::Bytes; +use codex_api::ApiError; +use codex_api::AuthError; use codex_api::AuthProvider; use codex_api::Compression; use codex_api::Provider; @@ -164,6 +166,59 @@ impl FlakyTransport { } } +#[derive(Clone)] +struct FailsOnceAuth { + attempts: Arc>, + error: Arc, +} + +impl FailsOnceAuth { + fn transient() -> Self { + Self { + attempts: Arc::new(Mutex::new(0)), + error: Arc::new(AuthError::Transient( + "sts temporarily unavailable".to_string(), + )), + } + } + + fn build() -> Self { + Self { + attempts: Arc::new(Mutex::new(0)), + error: Arc::new(AuthError::Build("invalid auth configuration".to_string())), + } + } + + fn attempts(&self) -> i64 { + *self + .attempts + .lock() + .unwrap_or_else(|err| panic!("mutex poisoned: {err}")) + } +} + +#[async_trait] +impl AuthProvider for FailsOnceAuth { + fn add_auth_headers(&self, _headers: &mut HeaderMap) {} + + async fn apply_auth(&self, request: Request) -> Result { + let mut attempts = self + .attempts + .lock() + .unwrap_or_else(|err| panic!("mutex poisoned: {err}")); + *attempts += 1; + + if *attempts == 1 { + return match self.error.as_ref() { + AuthError::Build(message) => Err(AuthError::Build(message.clone())), + AuthError::Transient(message) => Err(AuthError::Transient(message.clone())), + }; + } + + Ok(request) + } +} + #[async_trait] impl HttpTransport for FlakyTransport { async fn execute(&self, _req: Request) -> Result { @@ -296,6 +351,65 @@ async fn streaming_client_retries_on_transport_error() -> Result<()> { Ok(()) } +#[tokio::test] +async fn streaming_client_retries_on_transient_auth_error() -> Result<()> { + let state = RecordingState::default(); + let transport = RecordingTransport::new(state.clone()); + let auth = FailsOnceAuth::transient(); + + let mut provider = provider("openai"); + provider.retry.max_attempts = 2; + + let client = ResponsesClient::new(transport, provider, Arc::new(auth.clone())); + let body = serde_json::json!({ "model": "gpt-test" }); + let _stream = client + .stream( + body, + HeaderMap::new(), + Compression::None, + /*turn_state*/ None, + ) + .await?; + + assert_eq!(auth.attempts(), 2); + assert_eq!(state.take_stream_requests().len(), 1); + Ok(()) +} + +#[tokio::test] +async fn streaming_client_does_not_retry_auth_build_error() -> Result<()> { + let state = RecordingState::default(); + let transport = RecordingTransport::new(state.clone()); + let auth = FailsOnceAuth::build(); + + let mut provider = provider("openai"); + provider.retry.max_attempts = 2; + + let client = ResponsesClient::new(transport, provider, Arc::new(auth.clone())); + let body = serde_json::json!({ "model": "gpt-test" }); + let result = client + .stream( + body, + HeaderMap::new(), + Compression::None, + /*turn_state*/ None, + ) + .await; + let err = match result { + Ok(_) => panic!("auth build errors should fail without retry"), + Err(err) => err, + }; + + assert!(matches!( + err, + ApiError::Transport(TransportError::Build(message)) + if message == "invalid auth configuration" + )); + assert_eq!(auth.attempts(), 1); + assert_eq!(state.take_stream_requests().len(), 0); + Ok(()) +} + #[tokio::test] async fn azure_default_store_attaches_ids_and_headers() -> Result<()> { let state = RecordingState::default(); @@ -309,7 +423,6 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> { id: Some("msg_1".into()), role: "user".into(), content: vec![ContentItem::InputText { text: "hi".into() }], - end_turn: None, phase: None, }], tools: Vec::new(), diff --git a/codex-rs/codex-api/tests/sse_end_to_end.rs b/codex-rs/codex-api/tests/sse_end_to_end.rs index 107c101724..bf880fefcf 100644 --- a/codex-rs/codex-api/tests/sse_end_to_end.rs +++ b/codex-rs/codex-api/tests/sse_end_to_end.rs @@ -158,9 +158,11 @@ async fn responses_stream_parses_items_and_completed_end_to_end() -> Result<()> ResponseEvent::Completed { response_id, token_usage, + end_turn, } => { assert_eq!(response_id, "resp1"); assert!(token_usage.is_none()); + assert!(end_turn.is_none()); } other => panic!("unexpected third event: {other:?}"), } diff --git a/codex-rs/codex-client/src/lib.rs b/codex-rs/codex-client/src/lib.rs index acabec17a5..0f503fb3e2 100644 --- a/codex-rs/codex-client/src/lib.rs +++ b/codex-rs/codex-client/src/lib.rs @@ -25,6 +25,7 @@ pub use crate::default_client::CodexHttpClient; pub use crate::default_client::CodexRequestBuilder; pub use crate::error::StreamError; pub use crate::error::TransportError; +pub use crate::request::PreparedRequestBody; pub use crate::request::Request; pub use crate::request::RequestBody; pub use crate::request::RequestCompression; diff --git a/codex-rs/codex-client/src/request.rs b/codex-rs/codex-client/src/request.rs index 261bb1cf50..5fc076627f 100644 --- a/codex-rs/codex-client/src/request.rs +++ b/codex-rs/codex-client/src/request.rs @@ -1,6 +1,7 @@ use bytes::Bytes; use http::Method; use reqwest::header::HeaderMap; +use reqwest::header::HeaderValue; use serde::Serialize; use serde_json::Value; use std::time::Duration; @@ -27,6 +28,18 @@ impl RequestBody { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreparedRequestBody { + pub headers: HeaderMap, + pub body: Option, +} + +impl PreparedRequestBody { + pub fn body_bytes(&self) -> Bytes { + self.body.clone().unwrap_or_default() + } +} + #[derive(Debug, Clone)] pub struct Request { pub method: Method, @@ -63,6 +76,135 @@ impl Request { self.compression = compression; self } + + /// Convert the request body into the exact bytes that will be sent. + /// + /// Auth schemes such as AWS SigV4 need to sign the final body bytes, including + /// compression and content headers. Calling this method does not mutate the + /// request. + pub fn prepare_body_for_send(&self) -> Result { + let mut headers = self.headers.clone(); + match self.body.as_ref() { + Some(RequestBody::Raw(raw_body)) => { + if self.compression != RequestCompression::None { + return Err("request compression cannot be used with raw bodies".to_string()); + } + Ok(PreparedRequestBody { + headers, + body: Some(raw_body.clone()), + }) + } + Some(RequestBody::Json(body)) => { + let json = serde_json::to_vec(&body).map_err(|err| err.to_string())?; + let bytes = if self.compression != RequestCompression::None { + if headers.contains_key(http::header::CONTENT_ENCODING) { + return Err( + "request compression was requested but content-encoding is already set" + .to_string(), + ); + } + + let pre_compression_bytes = json.len(); + let compression_start = std::time::Instant::now(); + let (compressed, content_encoding) = match self.compression { + RequestCompression::None => unreachable!("guarded by compression != None"), + RequestCompression::Zstd => ( + zstd::stream::encode_all(std::io::Cursor::new(json), 3) + .map_err(|err| err.to_string())?, + HeaderValue::from_static("zstd"), + ), + }; + let post_compression_bytes = compressed.len(); + let compression_duration = compression_start.elapsed(); + + headers.insert(http::header::CONTENT_ENCODING, content_encoding); + + tracing::debug!( + pre_compression_bytes, + post_compression_bytes, + compression_duration_ms = compression_duration.as_millis(), + "Compressed request body with zstd" + ); + + compressed + } else { + json + }; + + if !headers.contains_key(http::header::CONTENT_TYPE) { + headers.insert( + http::header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + } + + Ok(PreparedRequestBody { + headers, + body: Some(Bytes::from(bytes)), + }) + } + None => Ok(PreparedRequestBody { + headers, + body: None, + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http::HeaderValue; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn prepare_body_for_send_serializes_json_and_sets_content_type() { + let request = Request::new(Method::POST, "https://example.com/v1/responses".to_string()) + .with_json(&json!({"model": "test-model"})); + + let prepared = request + .prepare_body_for_send() + .expect("body should prepare"); + + assert_eq!( + prepared.body, + Some(Bytes::from_static(br#"{"model":"test-model"}"#)) + ); + assert_eq!( + prepared + .headers + .get(http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/json") + ); + assert_eq!( + request.body, + Some(RequestBody::Json(json!({"model": "test-model"}))) + ); + assert_eq!(request.compression, RequestCompression::None); + } + + #[test] + fn prepare_body_for_send_rejects_existing_content_encoding_when_compressing() { + let mut request = + Request::new(Method::POST, "https://example.com/v1/responses".to_string()) + .with_json(&json!({"model": "test-model"})) + .with_compression(RequestCompression::Zstd); + request.headers.insert( + http::header::CONTENT_ENCODING, + HeaderValue::from_static("gzip"), + ); + + let err = request + .prepare_body_for_send() + .expect_err("conflicting content-encoding should fail"); + + assert_eq!( + err, + "request compression was requested but content-encoding is already set" + ); + } } #[derive(Debug, Clone)] diff --git a/codex-rs/codex-client/src/transport.rs b/codex-rs/codex-client/src/transport.rs index 590379003c..4ed062c483 100644 --- a/codex-rs/codex-client/src/transport.rs +++ b/codex-rs/codex-client/src/transport.rs @@ -3,7 +3,6 @@ use crate::default_client::CodexRequestBuilder; use crate::error::TransportError; use crate::request::Request; use crate::request::RequestBody; -use crate::request::RequestCompression; use crate::request::Response; use async_trait::async_trait; use bytes::Bytes; @@ -43,12 +42,14 @@ impl ReqwestTransport { } fn build(&self, req: Request) -> Result { + let prepared = req.prepare_body_for_send().map_err(TransportError::Build)?; + let Request { method, url, - mut headers, - body, - compression, + headers: _, + body: _, + compression: _, timeout, } = req; @@ -61,63 +62,9 @@ impl ReqwestTransport { builder = builder.timeout(timeout); } - match body { - Some(RequestBody::Raw(raw_body)) => { - if compression != RequestCompression::None { - return Err(TransportError::Build( - "request compression cannot be used with raw bodies".to_string(), - )); - } - builder = builder.headers(headers).body(raw_body); - } - Some(RequestBody::Json(body)) => { - if compression != RequestCompression::None { - if headers.contains_key(http::header::CONTENT_ENCODING) { - return Err(TransportError::Build( - "request compression was requested but content-encoding is already set" - .to_string(), - )); - } - - let json = serde_json::to_vec(&body) - .map_err(|err| TransportError::Build(err.to_string()))?; - let pre_compression_bytes = json.len(); - let compression_start = std::time::Instant::now(); - let (compressed, content_encoding) = match compression { - RequestCompression::None => unreachable!("guarded by compression != None"), - RequestCompression::Zstd => ( - zstd::stream::encode_all(std::io::Cursor::new(json), 3) - .map_err(|err| TransportError::Build(err.to_string()))?, - http::HeaderValue::from_static("zstd"), - ), - }; - let post_compression_bytes = compressed.len(); - let compression_duration = compression_start.elapsed(); - - // Ensure the server knows to unpack the request body. - headers.insert(http::header::CONTENT_ENCODING, content_encoding); - if !headers.contains_key(http::header::CONTENT_TYPE) { - headers.insert( - http::header::CONTENT_TYPE, - http::HeaderValue::from_static("application/json"), - ); - } - - tracing::info!( - pre_compression_bytes, - post_compression_bytes, - compression_duration_ms = compression_duration.as_millis(), - "Compressed request body with zstd" - ); - - builder = builder.headers(headers).body(compressed); - } else { - builder = builder.headers(headers).json(&body); - } - } - None => { - builder = builder.headers(headers); - } + builder = builder.headers(prepared.headers); + if let Some(body) = prepared.body { + builder = builder.body(body); } Ok(builder) } diff --git a/codex-rs/codex-experimental-api-macros/src/lib.rs b/codex-rs/codex-experimental-api-macros/src/lib.rs index c5099e40a5..2bca0190ea 100644 --- a/codex-rs/codex-experimental-api-macros/src/lib.rs +++ b/codex-rs/codex-experimental-api-macros/src/lib.rs @@ -261,11 +261,8 @@ fn presence_expr_for_access( access: proc_macro2::TokenStream, ty: &Type, ) -> proc_macro2::TokenStream { - if let Some(inner) = option_inner(ty) { - let inner_expr = presence_expr_for_ref(quote!(value), inner); - return quote! { - #access.as_ref().is_some_and(|value| #inner_expr) - }; + if option_inner(ty).is_some() { + return quote! { #access.is_some() }; } if is_vec_like(ty) || is_map_like(ty) { return quote! { !#access.is_empty() }; @@ -276,22 +273,6 @@ fn presence_expr_for_access( quote! { true } } -fn presence_expr_for_ref(access: proc_macro2::TokenStream, ty: &Type) -> proc_macro2::TokenStream { - if let Some(inner) = option_inner(ty) { - let inner_expr = presence_expr_for_ref(quote!(value), inner); - return quote! { - #access.as_ref().is_some_and(|value| #inner_expr) - }; - } - if is_vec_like(ty) || is_map_like(ty) { - return quote! { !#access.is_empty() }; - } - if is_bool(ty) { - return quote! { *#access }; - } - quote! { true } -} - fn option_inner(ty: &Type) -> Option<&Type> { let Type::Path(type_path) = ty else { return None; diff --git a/codex-rs/codex-mcp/Cargo.toml b/codex-rs/codex-mcp/Cargo.toml index 0aec1f3aaf..c3061adca9 100644 --- a/codex-rs/codex-mcp/Cargo.toml +++ b/codex-rs/codex-mcp/Cargo.toml @@ -15,9 +15,11 @@ workspace = true anyhow = { workspace = true } async-channel = { workspace = true } codex-async-utils = { workspace = true } +codex-api = { workspace = true } codex-config = { workspace = true } codex-exec-server = { workspace = true } codex-login = { workspace = true } +codex-model-provider = { workspace = true } codex-otel = { workspace = true } codex-plugin = { workspace = true } codex-protocol = { workspace = true } @@ -36,7 +38,6 @@ tracing = { workspace = true } url = { workspace = true } [dev-dependencies] -codex-utils-absolute-path = { workspace = true } pretty_assertions = { workspace = true } rmcp = { workspace = true, default-features = false, features = ["base64", "macros", "schemars", "server"] } tempfile = { workspace = true } diff --git a/codex-rs/codex-mcp/src/codex_apps.rs b/codex-rs/codex-mcp/src/codex_apps.rs new file mode 100644 index 0000000000..0a7981fb0d --- /dev/null +++ b/codex-rs/codex-mcp/src/codex_apps.rs @@ -0,0 +1,258 @@ +//! Codex Apps support for the built-in apps MCP server. +//! +//! This module owns the pieces that are unique to ChatGPT-hosted app +//! connectors: cache scoping by authenticated user, disk cache reads/writes, +//! connector allow-list filtering, and the normalization that turns app +//! connector/tool metadata into model-visible MCP callable names. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Instant; + +use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use crate::runtime::emit_duration; +use crate::tools::MCP_TOOLS_CACHE_WRITE_DURATION_METRIC; +use crate::tools::ToolInfo; +use codex_login::CodexAuth; +use codex_utils_plugins::mcp_connector::is_connector_id_allowed; +use codex_utils_plugins::mcp_connector::sanitize_name; +use serde::Deserialize; +use serde::Serialize; +use sha1::Digest; +use sha1::Sha1; + +pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 2; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CodexAppsToolsCacheKey { + pub(crate) account_id: Option, + pub(crate) chatgpt_user_id: Option, + pub(crate) is_workspace_account: bool, +} + +pub fn codex_apps_tools_cache_key(auth: Option<&CodexAuth>) -> CodexAppsToolsCacheKey { + CodexAppsToolsCacheKey { + account_id: auth.and_then(CodexAuth::get_account_id), + chatgpt_user_id: auth.and_then(CodexAuth::get_chatgpt_user_id), + is_workspace_account: auth.is_some_and(CodexAuth::is_workspace_account), + } +} + +pub fn filter_non_codex_apps_mcp_tools_only( + mcp_tools: &HashMap, +) -> HashMap { + mcp_tools + .iter() + .filter(|(_, tool)| tool.server_name != CODEX_APPS_MCP_SERVER_NAME) + .map(|(name, tool)| (name.clone(), tool.clone())) + .collect() +} + +#[derive(Clone)] +pub(crate) struct CodexAppsToolsCacheContext { + pub(crate) codex_home: PathBuf, + pub(crate) user_key: CodexAppsToolsCacheKey, +} + +impl CodexAppsToolsCacheContext { + pub(crate) fn cache_path(&self) -> PathBuf { + let user_key_json = serde_json::to_string(&self.user_key).unwrap_or_default(); + let user_key_hash = sha1_hex(&user_key_json); + self.codex_home + .join(CODEX_APPS_TOOLS_CACHE_DIR) + .join(format!("{user_key_hash}.json")) + } +} + +pub(crate) enum CachedCodexAppsToolsLoad { + Hit(Vec), + Missing, + Invalid, +} + +pub(crate) fn normalize_codex_apps_tool_title( + server_name: &str, + connector_name: Option<&str>, + value: &str, +) -> String { + if server_name != CODEX_APPS_MCP_SERVER_NAME { + return value.to_string(); + } + + let Some(connector_name) = connector_name + .map(str::trim) + .filter(|name| !name.is_empty()) + else { + return value.to_string(); + }; + + let prefix = format!("{connector_name}_"); + if let Some(stripped) = value.strip_prefix(&prefix) + && !stripped.is_empty() + { + return stripped.to_string(); + } + + value.to_string() +} + +pub(crate) fn normalize_codex_apps_callable_name( + server_name: &str, + tool_name: &str, + connector_id: Option<&str>, + connector_name: Option<&str>, +) -> String { + if server_name != CODEX_APPS_MCP_SERVER_NAME { + return tool_name.to_string(); + } + + let tool_name = sanitize_name(tool_name); + + if let Some(connector_name) = connector_name + .map(str::trim) + .map(sanitize_name) + .filter(|name| !name.is_empty()) + && let Some(stripped) = tool_name.strip_prefix(&connector_name) + && !stripped.is_empty() + { + return stripped.to_string(); + } + + if let Some(connector_id) = connector_id + .map(str::trim) + .map(sanitize_name) + .filter(|name| !name.is_empty()) + && let Some(stripped) = tool_name.strip_prefix(&connector_id) + && !stripped.is_empty() + { + return stripped.to_string(); + } + + tool_name +} + +pub(crate) fn normalize_codex_apps_callable_namespace( + server_name: &str, + connector_name: Option<&str>, +) -> String { + if server_name == CODEX_APPS_MCP_SERVER_NAME + && let Some(connector_name) = connector_name + { + format!("mcp__{}__{}", server_name, sanitize_name(connector_name)) + } else { + format!("mcp__{server_name}__") + } +} + +pub(crate) fn write_cached_codex_apps_tools_if_needed( + server_name: &str, + cache_context: Option<&CodexAppsToolsCacheContext>, + tools: &[ToolInfo], +) { + if server_name != CODEX_APPS_MCP_SERVER_NAME { + return; + } + + if let Some(cache_context) = cache_context { + let cache_write_start = Instant::now(); + write_cached_codex_apps_tools(cache_context, tools); + emit_duration( + MCP_TOOLS_CACHE_WRITE_DURATION_METRIC, + cache_write_start.elapsed(), + &[], + ); + } +} + +pub(crate) fn load_startup_cached_codex_apps_tools_snapshot( + server_name: &str, + cache_context: Option<&CodexAppsToolsCacheContext>, +) -> Option> { + if server_name != CODEX_APPS_MCP_SERVER_NAME { + return None; + } + + let cache_context = cache_context?; + + match load_cached_codex_apps_tools(cache_context) { + CachedCodexAppsToolsLoad::Hit(tools) => Some(tools), + CachedCodexAppsToolsLoad::Missing | CachedCodexAppsToolsLoad::Invalid => None, + } +} + +#[cfg(test)] +pub(crate) fn read_cached_codex_apps_tools( + cache_context: &CodexAppsToolsCacheContext, +) -> Option> { + match load_cached_codex_apps_tools(cache_context) { + CachedCodexAppsToolsLoad::Hit(tools) => Some(tools), + CachedCodexAppsToolsLoad::Missing | CachedCodexAppsToolsLoad::Invalid => None, + } +} + +pub(crate) fn load_cached_codex_apps_tools( + cache_context: &CodexAppsToolsCacheContext, +) -> CachedCodexAppsToolsLoad { + let cache_path = cache_context.cache_path(); + let bytes = match std::fs::read(cache_path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return CachedCodexAppsToolsLoad::Missing; + } + Err(_) => return CachedCodexAppsToolsLoad::Invalid, + }; + let cache: CodexAppsToolsDiskCache = match serde_json::from_slice(&bytes) { + Ok(cache) => cache, + Err(_) => return CachedCodexAppsToolsLoad::Invalid, + }; + if cache.schema_version != CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION { + return CachedCodexAppsToolsLoad::Invalid; + } + CachedCodexAppsToolsLoad::Hit(filter_disallowed_codex_apps_tools(cache.tools)) +} + +pub(crate) fn write_cached_codex_apps_tools( + cache_context: &CodexAppsToolsCacheContext, + tools: &[ToolInfo], +) { + let cache_path = cache_context.cache_path(); + if let Some(parent) = cache_path.parent() + && std::fs::create_dir_all(parent).is_err() + { + return; + } + let tools = filter_disallowed_codex_apps_tools(tools.to_vec()); + let Ok(bytes) = serde_json::to_vec_pretty(&CodexAppsToolsDiskCache { + schema_version: CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION, + tools, + }) else { + return; + }; + let _ = std::fs::write(cache_path, bytes); +} + +pub(crate) fn filter_disallowed_codex_apps_tools(tools: Vec) -> Vec { + tools + .into_iter() + .filter(|tool| { + tool.connector_id + .as_deref() + .is_none_or(is_connector_id_allowed) + }) + .collect() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CodexAppsToolsDiskCache { + schema_version: u8, + tools: Vec, +} + +const CODEX_APPS_TOOLS_CACHE_DIR: &str = "cache/codex_apps_tools"; + +fn sha1_hex(s: &str) -> String { + let mut hasher = Sha1::new(); + hasher.update(s.as_bytes()); + let sha1 = hasher.finalize(); + format!("{sha1:x}") +} diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs new file mode 100644 index 0000000000..9bbcbe12e7 --- /dev/null +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -0,0 +1,700 @@ +//! Aggregates MCP server connections for Codex. +//! +//! [`McpConnectionManager`] owns the set of running async RMCP clients keyed by +//! MCP server name. It coordinates startup status events, keeps server origin +//! metadata, aggregates tools/resources/templates across servers, routes tool +//! calls to the right client, and exposes the public manager API used by +//! `codex-core`. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; + +use crate::McpAuthStatusEntry; +use crate::codex_apps::CodexAppsToolsCacheContext; +use crate::codex_apps::CodexAppsToolsCacheKey; +use crate::codex_apps::write_cached_codex_apps_tools_if_needed; +use crate::elicitation::ElicitationRequestManager; +use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use crate::mcp::ToolPluginProvenance; +use crate::rmcp_client::AsyncManagedClient; +use crate::rmcp_client::DEFAULT_STARTUP_TIMEOUT; +use crate::rmcp_client::MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC; +use crate::rmcp_client::MCP_TOOLS_LIST_DURATION_METRIC; +use crate::rmcp_client::ManagedClient; +use crate::rmcp_client::StartupOutcomeError; +use crate::rmcp_client::list_tools_for_client_uncached; +use crate::runtime::McpRuntimeEnvironment; +use crate::runtime::emit_duration; +use crate::tools::ToolInfo; +use crate::tools::filter_tools; +use crate::tools::qualify_tools; +use crate::tools::tool_with_model_visible_input_schema; +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use async_channel::Sender; +use codex_config::Constrained; +use codex_config::McpServerConfig; +use codex_config::McpServerTransportConfig; +use codex_config::types::OAuthCredentialsStoreMode; +use codex_login::CodexAuth; +use codex_protocol::ToolName; +use codex_protocol::mcp::CallToolResult; +use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::McpStartupCompleteEvent; +use codex_protocol::protocol::McpStartupFailure; +use codex_protocol::protocol::McpStartupStatus; +use codex_protocol::protocol::McpStartupUpdateEvent; +use codex_rmcp_client::ElicitationResponse; +use rmcp::model::ListResourceTemplatesResult; +use rmcp::model::ListResourcesResult; +use rmcp::model::PaginatedRequestParams; +use rmcp::model::ReadResourceRequestParams; +use rmcp::model::ReadResourceResult; +use rmcp::model::RequestId; +use rmcp::model::Resource; +use rmcp::model::ResourceTemplate; +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; +use tracing::instrument; +use tracing::warn; +use url::Url; + +/// A thin wrapper around a set of running [`RmcpClient`] instances. +pub struct McpConnectionManager { + clients: HashMap, + server_origins: HashMap, + elicitation_requests: ElicitationRequestManager, +} + +impl McpConnectionManager { + pub fn new_uninitialized( + approval_policy: &Constrained, + permission_profile: &Constrained, + ) -> Self { + Self { + clients: HashMap::new(), + server_origins: HashMap::new(), + elicitation_requests: ElicitationRequestManager::new( + approval_policy.value(), + permission_profile.get().clone(), + ), + } + } + + pub fn has_servers(&self) -> bool { + !self.clients.is_empty() + } + + pub fn server_origin(&self, server_name: &str) -> Option<&str> { + self.server_origins.get(server_name).map(String::as_str) + } + + pub fn set_approval_policy(&self, approval_policy: &Constrained) { + if let Ok(mut policy) = self.elicitation_requests.approval_policy.lock() { + *policy = approval_policy.value(); + } + } + + pub fn set_permission_profile(&self, permission_profile: PermissionProfile) { + if let Ok(mut profile) = self.elicitation_requests.permission_profile.lock() { + *profile = permission_profile; + } + } + + #[allow(clippy::new_ret_no_self, clippy::too_many_arguments)] + pub async fn new( + mcp_servers: &HashMap, + store_mode: OAuthCredentialsStoreMode, + auth_entries: HashMap, + approval_policy: &Constrained, + submit_id: String, + tx_event: Sender, + initial_permission_profile: PermissionProfile, + runtime_environment: McpRuntimeEnvironment, + codex_home: PathBuf, + codex_apps_tools_cache_key: CodexAppsToolsCacheKey, + tool_plugin_provenance: ToolPluginProvenance, + auth: Option<&CodexAuth>, + ) -> (Self, CancellationToken) { + let cancel_token = CancellationToken::new(); + let mut clients = HashMap::new(); + let mut server_origins = HashMap::new(); + let mut join_set = JoinSet::new(); + let elicitation_requests = + ElicitationRequestManager::new(approval_policy.value(), initial_permission_profile); + let tool_plugin_provenance = Arc::new(tool_plugin_provenance); + let startup_submit_id = submit_id.clone(); + let codex_apps_auth_provider = auth + .filter(|auth| auth.uses_codex_backend()) + .map(codex_model_provider::auth_provider_from_auth); + let mcp_servers = mcp_servers.clone(); + for (server_name, cfg) in mcp_servers.into_iter().filter(|(_, cfg)| cfg.enabled) { + if let Some(origin) = transport_origin(&cfg.transport) { + server_origins.insert(server_name.clone(), origin); + } + let cancel_token = cancel_token.child_token(); + let _ = emit_update( + startup_submit_id.as_str(), + &tx_event, + McpStartupUpdateEvent { + server: server_name.clone(), + status: McpStartupStatus::Starting, + }, + ) + .await; + let codex_apps_tools_cache_context = if server_name == CODEX_APPS_MCP_SERVER_NAME { + Some(CodexAppsToolsCacheContext { + codex_home: codex_home.clone(), + user_key: codex_apps_tools_cache_key.clone(), + }) + } else { + None + }; + let uses_env_bearer_token = match &cfg.transport { + McpServerTransportConfig::StreamableHttp { + bearer_token_env_var, + .. + } => bearer_token_env_var.is_some(), + McpServerTransportConfig::Stdio { .. } => false, + }; + let runtime_auth_provider = + if server_name == CODEX_APPS_MCP_SERVER_NAME && !uses_env_bearer_token { + codex_apps_auth_provider.clone() + } else { + None + }; + let async_managed_client = AsyncManagedClient::new( + server_name.clone(), + cfg, + store_mode, + cancel_token.clone(), + tx_event.clone(), + elicitation_requests.clone(), + codex_apps_tools_cache_context, + Arc::clone(&tool_plugin_provenance), + runtime_environment.clone(), + runtime_auth_provider, + ); + clients.insert(server_name.clone(), async_managed_client.clone()); + let tx_event = tx_event.clone(); + let submit_id = startup_submit_id.clone(); + let auth_entry = auth_entries.get(&server_name).cloned(); + join_set.spawn(async move { + let mut outcome = async_managed_client.client().await; + if cancel_token.is_cancelled() { + outcome = Err(StartupOutcomeError::Cancelled); + } + let status = match &outcome { + Ok(_) => McpStartupStatus::Ready, + Err(StartupOutcomeError::Cancelled) => McpStartupStatus::Cancelled, + Err(error) => { + let error_str = mcp_init_error_display( + server_name.as_str(), + auth_entry.as_ref(), + error, + ); + McpStartupStatus::Failed { error: error_str } + } + }; + + let _ = emit_update( + submit_id.as_str(), + &tx_event, + McpStartupUpdateEvent { + server: server_name.clone(), + status, + }, + ) + .await; + + (server_name, outcome) + }); + } + let manager = Self { + clients, + server_origins, + elicitation_requests: elicitation_requests.clone(), + }; + tokio::spawn(async move { + let outcomes = join_set.join_all().await; + let mut summary = McpStartupCompleteEvent::default(); + for (server_name, outcome) in outcomes { + match outcome { + Ok(_) => summary.ready.push(server_name), + Err(StartupOutcomeError::Cancelled) => summary.cancelled.push(server_name), + Err(StartupOutcomeError::Failed { error }) => { + summary.failed.push(McpStartupFailure { + server: server_name, + error, + }) + } + } + } + let _ = tx_event + .send(Event { + id: startup_submit_id, + msg: EventMsg::McpStartupComplete(summary), + }) + .await; + }); + (manager, cancel_token) + } + + pub async fn resolve_elicitation( + &self, + server_name: String, + id: RequestId, + response: ElicitationResponse, + ) -> Result<()> { + self.elicitation_requests + .resolve(server_name, id, response) + .await + } + + pub async fn wait_for_server_ready(&self, server_name: &str, timeout: Duration) -> bool { + let Some(async_managed_client) = self.clients.get(server_name) else { + return false; + }; + + match tokio::time::timeout(timeout, async_managed_client.client()).await { + Ok(Ok(_)) => true, + Ok(Err(_)) | Err(_) => false, + } + } + + pub async fn required_startup_failures( + &self, + required_servers: &[String], + ) -> Vec { + let mut failures = Vec::new(); + for server_name in required_servers { + let Some(async_managed_client) = self.clients.get(server_name).cloned() else { + failures.push(McpStartupFailure { + server: server_name.clone(), + error: format!("required MCP server `{server_name}` was not initialized"), + }); + continue; + }; + + match async_managed_client.client().await { + Ok(_) => {} + Err(error) => failures.push(McpStartupFailure { + server: server_name.clone(), + error: startup_outcome_error_message(error), + }), + } + } + failures + } + + /// Returns a single map that contains all tools. Each key is the + /// fully-qualified name for the tool. + #[instrument(level = "trace", skip_all)] + pub async fn list_all_tools(&self) -> HashMap { + let mut tools = Vec::new(); + for managed_client in self.clients.values() { + let Some(server_tools) = managed_client.listed_tools().await else { + continue; + }; + tools.extend(server_tools); + } + qualify_tools(tools) + } + + /// Force-refresh codex apps tools by bypassing the in-process cache. + /// + /// On success, the refreshed tools replace the cache contents and the + /// latest filtered tool map is returned directly to the caller. On + /// failure, the existing cache remains unchanged. + pub async fn hard_refresh_codex_apps_tools_cache(&self) -> Result> { + let managed_client = self + .clients + .get(CODEX_APPS_MCP_SERVER_NAME) + .ok_or_else(|| anyhow!("unknown MCP server '{CODEX_APPS_MCP_SERVER_NAME}'"))? + .client() + .await + .context("failed to get client")?; + + let list_start = Instant::now(); + let fetch_start = Instant::now(); + let tools = list_tools_for_client_uncached( + CODEX_APPS_MCP_SERVER_NAME, + &managed_client.client, + managed_client.tool_timeout, + managed_client.server_instructions.as_deref(), + ) + .await + .with_context(|| { + format!("failed to refresh tools for MCP server '{CODEX_APPS_MCP_SERVER_NAME}'") + })?; + emit_duration( + MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC, + fetch_start.elapsed(), + &[], + ); + + write_cached_codex_apps_tools_if_needed( + CODEX_APPS_MCP_SERVER_NAME, + managed_client.codex_apps_tools_cache_context.as_ref(), + &tools, + ); + emit_duration( + MCP_TOOLS_LIST_DURATION_METRIC, + list_start.elapsed(), + &[("cache", "miss")], + ); + let tools = filter_tools(tools, &managed_client.tool_filter) + .into_iter() + .map(|mut tool| { + tool.tool = tool_with_model_visible_input_schema(&tool.tool); + tool + }); + Ok(qualify_tools(tools)) + } + + /// Returns a single map that contains all resources. Each key is the + /// server name and the value is a vector of resources. + pub async fn list_all_resources(&self) -> HashMap> { + let mut join_set = JoinSet::new(); + + let clients_snapshot = &self.clients; + + for (server_name, async_managed_client) in clients_snapshot { + let server_name = server_name.clone(); + let Ok(managed_client) = async_managed_client.client().await else { + continue; + }; + let timeout = managed_client.tool_timeout; + let client = managed_client.client.clone(); + + join_set.spawn(async move { + let mut collected: Vec = Vec::new(); + let mut cursor: Option = None; + + loop { + let params = cursor.as_ref().map(|next| PaginatedRequestParams { + meta: None, + cursor: Some(next.clone()), + }); + let response = match client.list_resources(params, timeout).await { + Ok(result) => result, + Err(err) => return (server_name, Err(err)), + }; + + collected.extend(response.resources); + + match response.next_cursor { + Some(next) => { + if cursor.as_ref() == Some(&next) { + return ( + server_name, + Err(anyhow!("resources/list returned duplicate cursor")), + ); + } + cursor = Some(next); + } + None => return (server_name, Ok(collected)), + } + } + }); + } + + let mut aggregated: HashMap> = HashMap::new(); + + while let Some(join_res) = join_set.join_next().await { + match join_res { + Ok((server_name, Ok(resources))) => { + aggregated.insert(server_name, resources); + } + Ok((server_name, Err(err))) => { + warn!("Failed to list resources for MCP server '{server_name}': {err:#}"); + } + Err(err) => { + warn!("Task panic when listing resources for MCP server: {err:#}"); + } + } + } + + aggregated + } + + /// Returns a single map that contains all resource templates. Each key is the + /// server name and the value is a vector of resource templates. + pub async fn list_all_resource_templates(&self) -> HashMap> { + let mut join_set = JoinSet::new(); + + let clients_snapshot = &self.clients; + + for (server_name, async_managed_client) in clients_snapshot { + let server_name_cloned = server_name.clone(); + let Ok(managed_client) = async_managed_client.client().await else { + continue; + }; + let client = managed_client.client.clone(); + let timeout = managed_client.tool_timeout; + + join_set.spawn(async move { + let mut collected: Vec = Vec::new(); + let mut cursor: Option = None; + + loop { + let params = cursor.as_ref().map(|next| PaginatedRequestParams { + meta: None, + cursor: Some(next.clone()), + }); + let response = match client.list_resource_templates(params, timeout).await { + Ok(result) => result, + Err(err) => return (server_name_cloned, Err(err)), + }; + + collected.extend(response.resource_templates); + + match response.next_cursor { + Some(next) => { + if cursor.as_ref() == Some(&next) { + return ( + server_name_cloned, + Err(anyhow!( + "resources/templates/list returned duplicate cursor" + )), + ); + } + cursor = Some(next); + } + None => return (server_name_cloned, Ok(collected)), + } + } + }); + } + + let mut aggregated: HashMap> = HashMap::new(); + + while let Some(join_res) = join_set.join_next().await { + match join_res { + Ok((server_name, Ok(templates))) => { + aggregated.insert(server_name, templates); + } + Ok((server_name, Err(err))) => { + warn!( + "Failed to list resource templates for MCP server '{server_name}': {err:#}" + ); + } + Err(err) => { + warn!("Task panic when listing resource templates for MCP server: {err:#}"); + } + } + } + + aggregated + } + + /// Invoke the tool indicated by the (server, tool) pair. + pub async fn call_tool( + &self, + server: &str, + tool: &str, + arguments: Option, + meta: Option, + ) -> Result { + let client = self.client_by_name(server).await?; + if !client.tool_filter.allows(tool) { + return Err(anyhow!( + "tool '{tool}' is disabled for MCP server '{server}'" + )); + } + + let result: rmcp::model::CallToolResult = client + .client + .call_tool(tool.to_string(), arguments, meta, client.tool_timeout) + .await + .with_context(|| format!("tool call failed for `{server}/{tool}`"))?; + + let content = result + .content + .into_iter() + .map(|content| { + serde_json::to_value(content) + .unwrap_or_else(|_| serde_json::Value::String("".to_string())) + }) + .collect(); + + Ok(CallToolResult { + content, + structured_content: result.structured_content, + is_error: result.is_error, + meta: result.meta.and_then(|meta| serde_json::to_value(meta).ok()), + }) + } + + pub async fn server_supports_sandbox_state_meta_capability( + &self, + server: &str, + ) -> Result { + Ok(self + .client_by_name(server) + .await? + .server_supports_sandbox_state_meta_capability) + } + + /// List resources from the specified server. + pub async fn list_resources( + &self, + server: &str, + params: Option, + ) -> Result { + let managed = self.client_by_name(server).await?; + let timeout = managed.tool_timeout; + + managed + .client + .list_resources(params, timeout) + .await + .with_context(|| format!("resources/list failed for `{server}`")) + } + + /// List resource templates from the specified server. + pub async fn list_resource_templates( + &self, + server: &str, + params: Option, + ) -> Result { + let managed = self.client_by_name(server).await?; + let client = managed.client.clone(); + let timeout = managed.tool_timeout; + + client + .list_resource_templates(params, timeout) + .await + .with_context(|| format!("resources/templates/list failed for `{server}`")) + } + + /// Read a resource from the specified server. + pub async fn read_resource( + &self, + server: &str, + params: ReadResourceRequestParams, + ) -> Result { + let managed = self.client_by_name(server).await?; + let client = managed.client.clone(); + let timeout = managed.tool_timeout; + let uri = params.uri.clone(); + + client + .read_resource(params, timeout) + .await + .with_context(|| format!("resources/read failed for `{server}` ({uri})")) + } + + pub async fn resolve_tool_info(&self, tool_name: &ToolName) -> Option { + let all_tools = self.list_all_tools().await; + all_tools + .into_values() + .find(|tool| tool.canonical_tool_name() == *tool_name) + } + + async fn client_by_name(&self, name: &str) -> Result { + self.clients + .get(name) + .ok_or_else(|| anyhow!("unknown MCP server '{name}'"))? + .client() + .await + .context("failed to get client") + } +} + +async fn emit_update( + submit_id: &str, + tx_event: &Sender, + update: McpStartupUpdateEvent, +) -> Result<(), async_channel::SendError> { + tx_event + .send(Event { + id: submit_id.to_string(), + msg: EventMsg::McpStartupUpdate(update), + }) + .await +} + +fn transport_origin(transport: &McpServerTransportConfig) -> Option { + match transport { + McpServerTransportConfig::StreamableHttp { url, .. } => { + let parsed = Url::parse(url).ok()?; + Some(parsed.origin().ascii_serialization()) + } + McpServerTransportConfig::Stdio { .. } => Some("stdio".to_string()), + } +} + +fn mcp_init_error_display( + server_name: &str, + entry: Option<&McpAuthStatusEntry>, + err: &StartupOutcomeError, +) -> String { + if let Some(McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers, + .. + }) = &entry.map(|entry| &entry.config.transport) + && url == "https://api.githubcopilot.com/mcp/" + && bearer_token_env_var.is_none() + && http_headers.as_ref().map(HashMap::is_empty).unwrap_or(true) + { + format!( + "GitHub MCP does not support OAuth. Log in by adding a personal access token (https://github.com/settings/personal-access-tokens) to your environment and config.toml:\n[mcp_servers.{server_name}]\nbearer_token_env_var = CODEX_GITHUB_PERSONAL_ACCESS_TOKEN" + ) + } else if is_mcp_client_auth_required_error(err) { + format!( + "The {server_name} MCP server is not logged in. Run `codex mcp login {server_name}`." + ) + } else if is_mcp_client_startup_timeout_error(err) { + let startup_timeout_secs = match entry { + Some(entry) => match entry.config.startup_timeout_sec { + Some(timeout) => timeout, + None => DEFAULT_STARTUP_TIMEOUT, + }, + None => DEFAULT_STARTUP_TIMEOUT, + } + .as_secs(); + format!( + "MCP client for `{server_name}` timed out after {startup_timeout_secs} seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.{server_name}]\nstartup_timeout_sec = XX" + ) + } else { + format!("MCP client for `{server_name}` failed to start: {err:#}") + } +} + +fn startup_outcome_error_message(error: StartupOutcomeError) -> String { + match error { + StartupOutcomeError::Cancelled => "MCP startup cancelled".to_string(), + StartupOutcomeError::Failed { error } => error, + } +} + +fn is_mcp_client_auth_required_error(error: &StartupOutcomeError) -> bool { + match error { + StartupOutcomeError::Failed { error } => error.contains("Auth required"), + _ => false, + } +} + +fn is_mcp_client_startup_timeout_error(error: &StartupOutcomeError) -> bool { + match error { + StartupOutcomeError::Failed { error } => { + error.contains("request timed out") + || error.contains("timed out handshaking with MCP server") + } + _ => false, + } +} + +#[cfg(test)] +#[path = "connection_manager_tests.rs"] +mod tests; diff --git a/codex-rs/codex-mcp/src/mcp_connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs similarity index 92% rename from codex-rs/codex-mcp/src/mcp_connection_manager_tests.rs rename to codex-rs/codex-mcp/src/connection_manager_tests.rs index cf2889ccde..02c4bcc733 100644 --- a/codex-rs/codex-mcp/src/mcp_connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -1,11 +1,36 @@ use super::*; +use crate::codex_apps::CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION; +use crate::codex_apps::CodexAppsToolsCacheContext; +use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot; +use crate::codex_apps::read_cached_codex_apps_tools; +use crate::codex_apps::write_cached_codex_apps_tools; +use crate::declared_openai_file_input_param_names; +use crate::elicitation::ElicitationRequestManager; +use crate::elicitation::elicitation_is_rejected_by_policy; +use crate::rmcp_client::AsyncManagedClient; +use crate::rmcp_client::ManagedClient; +use crate::rmcp_client::StartupOutcomeError; +use crate::rmcp_client::elicitation_capability_for_server; +use crate::tools::ToolFilter; +use crate::tools::ToolInfo; +use crate::tools::filter_tools; +use crate::tools::qualify_tools; +use crate::tools::tool_with_model_visible_input_schema; +use codex_config::Constrained; use codex_protocol::ToolName; +use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::McpAuthStatus; +use futures::FutureExt; use pretty_assertions::assert_eq; +use rmcp::model::CreateElicitationRequestParams; +use rmcp::model::ElicitationAction; +use rmcp::model::ElicitationCapability; +use rmcp::model::FormElicitationCapability; use rmcp::model::JsonObject; use rmcp::model::Meta; use rmcp::model::NumberOrString; +use rmcp::model::Tool; use std::collections::HashSet; use std::sync::Arc; use tempfile::tempdir; @@ -179,9 +204,9 @@ fn elicitation_granular_policy_respects_never_and_config() { } #[tokio::test] -async fn full_access_auto_accepts_elicitation_with_empty_form_schema() { +async fn disabled_permissions_auto_accept_elicitation_with_empty_form_schema() { let manager = - ElicitationRequestManager::new(AskForApproval::Never, SandboxPolicy::DangerFullAccess); + ElicitationRequestManager::new(AskForApproval::Never, PermissionProfile::Disabled); let (tx_event, _rx_event) = async_channel::bounded(1); let sender = manager.make_sender("server".to_string(), tx_event); @@ -209,9 +234,9 @@ async fn full_access_auto_accepts_elicitation_with_empty_form_schema() { } #[tokio::test] -async fn full_access_does_not_auto_accept_elicitation_with_requested_fields() { +async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fields() { let manager = - ElicitationRequestManager::new(AskForApproval::Never, SandboxPolicy::DangerFullAccess); + ElicitationRequestManager::new(AskForApproval::Never, PermissionProfile::Disabled); let (tx_event, _rx_event) = async_channel::bounded(1); let sender = manager.make_sender("server".to_string(), tx_event); @@ -627,8 +652,9 @@ async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() { .boxed() .shared(); let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); - let sandbox_policy = Constrained::allow_any(SandboxPolicy::new_read_only_policy()); - let mut manager = McpConnectionManager::new_uninitialized(&approval_policy, &sandbox_policy); + let permission_profile = Constrained::allow_any(PermissionProfile::default()); + let mut manager = + McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile); manager.clients.insert( CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { @@ -654,8 +680,9 @@ async fn resolve_tool_info_accepts_canonical_namespaced_tool_names() { .boxed() .shared(); let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); - let sandbox_policy = Constrained::allow_any(SandboxPolicy::new_read_only_policy()); - let mut manager = McpConnectionManager::new_uninitialized(&approval_policy, &sandbox_policy); + let permission_profile = Constrained::allow_any(PermissionProfile::default()); + let mut manager = + McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile); manager.clients.insert( "rmcp".to_string(), AsyncManagedClient { @@ -689,8 +716,9 @@ async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot( .boxed() .shared(); let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); - let sandbox_policy = Constrained::allow_any(SandboxPolicy::new_read_only_policy()); - let mut manager = McpConnectionManager::new_uninitialized(&approval_policy, &sandbox_policy); + let permission_profile = Constrained::allow_any(PermissionProfile::default()); + let mut manager = + McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile); manager.clients.insert( CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { @@ -712,8 +740,9 @@ async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty( .boxed() .shared(); let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); - let sandbox_policy = Constrained::allow_any(SandboxPolicy::new_read_only_policy()); - let mut manager = McpConnectionManager::new_uninitialized(&approval_policy, &sandbox_policy); + let permission_profile = Constrained::allow_any(PermissionProfile::default()); + let mut manager = + McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile); manager.clients.insert( CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { @@ -744,8 +773,9 @@ async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() { .boxed() .shared(); let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); - let sandbox_policy = Constrained::allow_any(SandboxPolicy::new_read_only_policy()); - let mut manager = McpConnectionManager::new_uninitialized(&approval_policy, &sandbox_policy); + let permission_profile = Constrained::allow_any(PermissionProfile::default()); + let mut manager = + McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile); let startup_complete = Arc::new(std::sync::atomic::AtomicBool::new(true)); manager.clients.insert( CODEX_APPS_MCP_SERVER_NAME.to_string(), diff --git a/codex-rs/codex-mcp/src/elicitation.rs b/codex-rs/codex-mcp/src/elicitation.rs new file mode 100644 index 0000000000..101bda4125 --- /dev/null +++ b/codex-rs/codex-mcp/src/elicitation.rs @@ -0,0 +1,190 @@ +//! MCP elicitation request tracking and policy handling. +//! +//! RMCP clients call into this module when a server asks Codex to elicit data +//! from the user. It decides whether the request can be automatically accepted, +//! must be declined by policy, or should be surfaced as a Codex protocol event +//! and later resolved through the stored responder. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::Mutex as StdMutex; + +use crate::mcp::mcp_permission_prompt_is_auto_approved; +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use async_channel::Sender; +use codex_protocol::approvals::ElicitationRequest; +use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::mcp::RequestId as ProtocolRequestId; +use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_rmcp_client::ElicitationResponse; +use codex_rmcp_client::SendElicitation; +use futures::future::FutureExt; +use rmcp::model::CreateElicitationRequestParams; +use rmcp::model::ElicitationAction; +use rmcp::model::RequestId; +use tokio::sync::Mutex; +use tokio::sync::oneshot; + +#[derive(Clone)] +pub(crate) struct ElicitationRequestManager { + requests: Arc>, + pub(crate) approval_policy: Arc>, + pub(crate) permission_profile: Arc>, +} + +impl ElicitationRequestManager { + pub(crate) fn new( + approval_policy: AskForApproval, + permission_profile: PermissionProfile, + ) -> Self { + Self { + requests: Arc::new(Mutex::new(HashMap::new())), + approval_policy: Arc::new(StdMutex::new(approval_policy)), + permission_profile: Arc::new(StdMutex::new(permission_profile)), + } + } + + pub(crate) async fn resolve( + &self, + server_name: String, + id: RequestId, + response: ElicitationResponse, + ) -> Result<()> { + self.requests + .lock() + .await + .remove(&(server_name, id)) + .ok_or_else(|| anyhow!("elicitation request not found"))? + .send(response) + .map_err(|e| anyhow!("failed to send elicitation response: {e:?}")) + } + + pub(crate) fn make_sender( + &self, + server_name: String, + tx_event: Sender, + ) -> SendElicitation { + let elicitation_requests = self.requests.clone(); + let approval_policy = self.approval_policy.clone(); + let permission_profile = self.permission_profile.clone(); + Box::new(move |id, elicitation| { + let elicitation_requests = elicitation_requests.clone(); + let tx_event = tx_event.clone(); + let server_name = server_name.clone(); + let approval_policy = approval_policy.clone(); + let permission_profile = permission_profile.clone(); + async move { + let approval_policy = approval_policy + .lock() + .map(|policy| *policy) + .unwrap_or(AskForApproval::Never); + let permission_profile = permission_profile + .lock() + .map(|profile| profile.clone()) + .unwrap_or_default(); + if mcp_permission_prompt_is_auto_approved(approval_policy, &permission_profile) + && can_auto_accept_elicitation(&elicitation) + { + return Ok(ElicitationResponse { + action: ElicitationAction::Accept, + content: Some(serde_json::json!({})), + meta: None, + }); + } + + if elicitation_is_rejected_by_policy(approval_policy) { + return Ok(ElicitationResponse { + action: ElicitationAction::Decline, + content: None, + meta: None, + }); + } + + let request = match elicitation { + CreateElicitationRequestParams::FormElicitationParams { + meta, + message, + requested_schema, + } => ElicitationRequest::Form { + meta: meta + .map(serde_json::to_value) + .transpose() + .context("failed to serialize MCP elicitation metadata")?, + message, + requested_schema: serde_json::to_value(requested_schema) + .context("failed to serialize MCP elicitation schema")?, + }, + CreateElicitationRequestParams::UrlElicitationParams { + meta, + message, + url, + elicitation_id, + } => ElicitationRequest::Url { + meta: meta + .map(serde_json::to_value) + .transpose() + .context("failed to serialize MCP elicitation metadata")?, + message, + url, + elicitation_id, + }, + }; + let (tx, rx) = oneshot::channel(); + { + let mut lock = elicitation_requests.lock().await; + lock.insert((server_name.clone(), id.clone()), tx); + } + let _ = tx_event + .send(Event { + id: "mcp_elicitation_request".to_string(), + msg: EventMsg::ElicitationRequest(ElicitationRequestEvent { + turn_id: None, + server_name, + id: match id.clone() { + rmcp::model::NumberOrString::String(value) => { + ProtocolRequestId::String(value.to_string()) + } + rmcp::model::NumberOrString::Number(value) => { + ProtocolRequestId::Integer(value) + } + }, + request, + }), + }) + .await; + rx.await + .context("elicitation request channel closed unexpectedly") + } + .boxed() + }) + } +} + +pub(crate) fn elicitation_is_rejected_by_policy(approval_policy: AskForApproval) -> bool { + match approval_policy { + AskForApproval::Never => true, + AskForApproval::OnFailure => false, + AskForApproval::OnRequest => false, + AskForApproval::UnlessTrusted => false, + AskForApproval::Granular(granular_config) => !granular_config.allows_mcp_elicitations(), + } +} + +type ResponderMap = HashMap<(String, RequestId), oneshot::Sender>; + +fn can_auto_accept_elicitation(elicitation: &CreateElicitationRequestParams) -> bool { + match elicitation { + CreateElicitationRequestParams::FormElicitationParams { + requested_schema, .. + } => { + // Auto-accept confirm/approval elicitations without schema requirements. + requested_schema.properties.is_empty() + } + CreateElicitationRequestParams::UrlElicitationParams { .. } => false, + } +} diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index 8b77086e5e..1d3fd17619 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -1,47 +1,47 @@ -pub(crate) mod mcp; -pub(crate) mod mcp_connection_manager; -pub(crate) mod mcp_tool_names; +pub use connection_manager::McpConnectionManager; +pub use rmcp_client::MCP_SANDBOX_STATE_META_CAPABILITY; +pub use runtime::McpRuntimeEnvironment; +pub use runtime::SandboxState; +pub use tools::ToolInfo; pub use mcp::CODEX_APPS_MCP_SERVER_NAME; -pub use mcp::McpAuthStatusEntry; pub use mcp::McpConfig; -pub use mcp::McpManager; +pub use mcp::ToolPluginProvenance; + +pub use codex_apps::CodexAppsToolsCacheKey; +pub use codex_apps::codex_apps_tools_cache_key; + +pub use mcp::configured_mcp_servers; +pub use mcp::effective_mcp_servers; +pub use mcp::tool_plugin_provenance; +pub use mcp::with_codex_apps_mcp; + +pub use mcp::McpServerStatusSnapshot; +pub use mcp::McpSnapshotDetail; +pub use mcp::collect_mcp_server_status_snapshot_with_detail; +pub use mcp::collect_mcp_snapshot_from_manager; +pub use mcp::read_mcp_resource; + +pub use mcp::McpAuthStatusEntry; pub use mcp::McpOAuthLoginConfig; pub use mcp::McpOAuthLoginSupport; pub use mcp::McpOAuthScopesSource; -pub use mcp::McpServerStatusSnapshot; -pub use mcp::McpSnapshotDetail; pub use mcp::ResolvedMcpOAuthScopes; -pub use mcp::ToolPluginProvenance; -pub use mcp::canonical_mcp_server_key; -pub use mcp::collect_mcp_server_status_snapshot; -pub use mcp::collect_mcp_server_status_snapshot_with_detail; -pub use mcp::collect_mcp_snapshot; -pub use mcp::collect_mcp_snapshot_from_manager; -pub use mcp::collect_mcp_snapshot_from_manager_with_detail; -pub use mcp::collect_mcp_snapshot_with_detail; -pub use mcp::collect_missing_mcp_dependencies; pub use mcp::compute_auth_statuses; -pub use mcp::configured_mcp_servers; pub use mcp::discover_supported_scopes; -pub use mcp::effective_mcp_servers; -pub use mcp::group_tools_by_server; -pub use mcp::mcp_permission_prompt_is_auto_approved; pub use mcp::oauth_login_support; -pub use mcp::qualified_mcp_tool_name_prefix; -pub use mcp::read_mcp_resource; pub use mcp::resolve_oauth_scopes; pub use mcp::should_retry_without_scopes; -pub use mcp::split_qualified_tool_name; -pub use mcp::tool_plugin_provenance; -pub use mcp::with_codex_apps_mcp; -pub use mcp_connection_manager::CodexAppsToolsCacheKey; -pub use mcp_connection_manager::DEFAULT_STARTUP_TIMEOUT; -pub use mcp_connection_manager::MCP_SANDBOX_STATE_META_CAPABILITY; -pub use mcp_connection_manager::McpConnectionManager; -pub use mcp_connection_manager::McpRuntimeEnvironment; -pub use mcp_connection_manager::SandboxState; -pub use mcp_connection_manager::ToolInfo; -pub use mcp_connection_manager::codex_apps_tools_cache_key; -pub use mcp_connection_manager::declared_openai_file_input_param_names; -pub use mcp_connection_manager::filter_non_codex_apps_mcp_tools_only; + +pub use codex_apps::filter_non_codex_apps_mcp_tools_only; +pub use mcp::mcp_permission_prompt_is_auto_approved; +pub use mcp::qualified_mcp_tool_name_prefix; +pub use tools::declared_openai_file_input_param_names; + +pub(crate) mod codex_apps; +pub(crate) mod connection_manager; +pub(crate) mod elicitation; +pub(crate) mod mcp; +pub(crate) mod rmcp_client; +pub(crate) mod runtime; +pub(crate) mod tools; diff --git a/codex-rs/codex-mcp/src/mcp/auth.rs b/codex-rs/codex-mcp/src/mcp/auth.rs index 27d7e13358..6a97b52789 100644 --- a/codex-rs/codex-mcp/src/mcp/auth.rs +++ b/codex-rs/codex-mcp/src/mcp/auth.rs @@ -1,7 +1,10 @@ use std::collections::HashMap; use anyhow::Result; +use codex_config::McpServerConfig; +use codex_config::McpServerTransportConfig; use codex_config::types::OAuthCredentialsStoreMode; +use codex_login::CodexAuth; use codex_protocol::protocol::McpAuthStatus; use codex_rmcp_client::OAuthProviderError; use codex_rmcp_client::determine_streamable_http_auth_status; @@ -9,8 +12,7 @@ use codex_rmcp_client::discover_streamable_http_oauth; use futures::future::join_all; use tracing::warn; -use codex_config::McpServerConfig; -use codex_config::McpServerTransportConfig; +use super::CODEX_APPS_MCP_SERVER_NAME; #[derive(Debug, Clone)] pub struct McpOAuthLoginConfig { @@ -41,6 +43,12 @@ pub struct ResolvedMcpOAuthScopes { pub source: McpOAuthScopesSource, } +#[derive(Debug, Clone)] +pub struct McpAuthStatusEntry { + pub config: McpServerConfig, + pub auth_status: McpAuthStatus, +} + pub async fn oauth_login_support(transport: &McpServerTransportConfig) -> McpOAuthLoginSupport { let McpServerTransportConfig::StreamableHttp { url, @@ -117,15 +125,10 @@ pub fn should_retry_without_scopes(scopes: &ResolvedMcpOAuthScopes, error: &anyh && error.downcast_ref::().is_some() } -#[derive(Debug, Clone)] -pub struct McpAuthStatusEntry { - pub config: McpServerConfig, - pub auth_status: McpAuthStatus, -} - pub async fn compute_auth_statuses<'a, I>( servers: I, store_mode: OAuthCredentialsStoreMode, + auth: Option<&CodexAuth>, ) -> HashMap where I: IntoIterator, @@ -133,14 +136,24 @@ where let futures = servers.into_iter().map(|(name, config)| { let name = name.clone(); let config = config.clone(); - async move { - let auth_status = match compute_auth_status(&name, &config, store_mode).await { - Ok(status) => status, - Err(error) => { - warn!("failed to determine auth status for MCP server `{name}`: {error:?}"); - McpAuthStatus::Unsupported + let has_runtime_auth = name == CODEX_APPS_MCP_SERVER_NAME + && auth.is_some_and(CodexAuth::uses_codex_backend) + && matches!( + &config.transport, + McpServerTransportConfig::StreamableHttp { + bearer_token_env_var: None, + .. } - }; + ); + async move { + let auth_status = + match compute_auth_status(&name, &config, store_mode, has_runtime_auth).await { + Ok(status) => status, + Err(error) => { + warn!("failed to determine auth status for MCP server `{name}`: {error:?}"); + McpAuthStatus::Unsupported + } + }; let entry = McpAuthStatusEntry { config, auth_status, @@ -156,11 +169,16 @@ async fn compute_auth_status( server_name: &str, config: &McpServerConfig, store_mode: OAuthCredentialsStoreMode, + has_runtime_auth: bool, ) -> Result { if !config.enabled { return Ok(McpAuthStatus::Unsupported); } + if has_runtime_auth { + return Ok(McpAuthStatus::BearerToken); + } + match &config.transport { McpServerTransportConfig::Stdio { .. } => Ok(McpAuthStatus::Unsupported), McpServerTransportConfig::StreamableHttp { diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 97053cbe53..080ac889de 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -1,5 +1,3 @@ -pub(crate) mod auth; -mod skill_dependencies; pub use auth::McpAuthStatusEntry; pub use auth::McpOAuthLoginConfig; pub use auth::McpOAuthLoginSupport; @@ -10,8 +8,8 @@ pub use auth::discover_supported_scopes; pub use auth::oauth_login_support; pub use auth::resolve_oauth_scopes; pub use auth::should_retry_without_scopes; -pub use skill_dependencies::canonical_mcp_server_key; -pub use skill_dependencies::collect_missing_mcp_dependencies; + +pub(crate) mod auth; use std::collections::HashMap; use std::env; @@ -28,22 +26,21 @@ use codex_plugin::PluginCapabilitySummary; use codex_protocol::mcp::Resource; use codex_protocol::mcp::ResourceTemplate; use codex_protocol::mcp::Tool; +use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::McpAuthStatus; use codex_protocol::protocol::McpListToolsResponseEvent; -use codex_protocol::protocol::SandboxPolicy; use rmcp::model::ReadResourceRequestParams; use rmcp::model::ReadResourceResult; use serde_json::Value; -use crate::mcp_connection_manager::McpConnectionManager; -use crate::mcp_connection_manager::McpRuntimeEnvironment; -use crate::mcp_connection_manager::codex_apps_tools_cache_key; -pub type McpManager = McpConnectionManager; +use crate::codex_apps::codex_apps_tools_cache_key; +use crate::connection_manager::McpConnectionManager; +use crate::runtime::McpRuntimeEnvironment; +pub const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps"; const MCP_TOOL_NAME_PREFIX: &str = "mcp"; const MCP_TOOL_NAME_DELIMITER: &str = "__"; -pub const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps"; const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN"; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -59,26 +56,6 @@ impl McpSnapshotDetail { } } -/// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`. -/// MCP server/tool names are user-controlled, so sanitize the fully-qualified -/// name we expose to the model by replacing any disallowed character with `_`. -pub(crate) fn sanitize_responses_api_tool_name(name: &str) -> String { - let mut sanitized = String::with_capacity(name.len()); - for c in name.chars() { - if c.is_ascii_alphanumeric() || c == '_' { - sanitized.push(c); - } else { - sanitized.push('_'); - } - } - - if sanitized.is_empty() { - "_".to_string() - } else { - sanitized - } -} - pub fn qualified_mcp_tool_name_prefix(server_name: &str) -> String { sanitize_responses_api_tool_name(&format!( "{MCP_TOOL_NAME_PREFIX}{MCP_TOOL_NAME_DELIMITER}{server_name}{MCP_TOOL_NAME_DELIMITER}" @@ -89,13 +66,18 @@ pub fn qualified_mcp_tool_name_prefix(server_name: &str) -> String { /// of being shown to the user. pub fn mcp_permission_prompt_is_auto_approved( approval_policy: AskForApproval, - sandbox_policy: &SandboxPolicy, + permission_profile: &PermissionProfile, ) -> bool { - approval_policy == AskForApproval::Never - && matches!( - sandbox_policy, - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } - ) + if approval_policy != AskForApproval::Never { + return false; + } + + match permission_profile { + PermissionProfile::Disabled | PermissionProfile::External { .. } => true, + PermissionProfile::Managed { file_system, .. } => { + file_system.to_sandbox_policy().has_full_disk_write_access() + } + } } /// MCP runtime settings derived from `codex_core::config::Config`. @@ -105,7 +87,7 @@ pub fn mcp_permission_prompt_is_auto_approved( /// approval/sandbox policy, locate OAuth state, and merge plugin-provided MCP /// servers. Request-scoped or auth-scoped state should not be stored here; /// thread those values explicitly into runtime entry points such as -/// [`with_codex_apps_mcp`] and [`collect_mcp_snapshot`] so config objects do +/// [`with_codex_apps_mcp`] and snapshot collection helpers so config objects do /// not go stale when auth changes. #[derive(Debug, Clone)] pub struct McpConfig { @@ -196,107 +178,15 @@ impl ToolPluginProvenance { } } -fn codex_apps_mcp_bearer_token_env_var() -> Option { - match env::var(CODEX_CONNECTORS_TOKEN_ENV_VAR) { - Ok(value) if !value.trim().is_empty() => Some(CODEX_CONNECTORS_TOKEN_ENV_VAR.to_string()), - Ok(_) => None, - Err(env::VarError::NotPresent) => None, - Err(env::VarError::NotUnicode(_)) => Some(CODEX_CONNECTORS_TOKEN_ENV_VAR.to_string()), - } -} - -fn codex_apps_mcp_bearer_token(auth: Option<&CodexAuth>) -> Option { - let token = auth.and_then(|auth| auth.get_token().ok())?; - let token = token.trim(); - if token.is_empty() { - None - } else { - Some(token.to_string()) - } -} - -fn codex_apps_mcp_http_headers(auth: Option<&CodexAuth>) -> Option> { - let mut headers = HashMap::new(); - if let Some(token) = codex_apps_mcp_bearer_token(auth) { - headers.insert("Authorization".to_string(), format!("Bearer {token}")); - } - if let Some(account_id) = auth.and_then(CodexAuth::get_account_id) { - headers.insert("ChatGPT-Account-ID".to_string(), account_id); - } - if headers.is_empty() { - None - } else { - Some(headers) - } -} - -fn normalize_codex_apps_base_url(base_url: &str) -> String { - let mut base_url = base_url.trim_end_matches('/').to_string(); - if (base_url.starts_with("https://chatgpt.com") - || base_url.starts_with("https://chat.openai.com")) - && !base_url.contains("/backend-api") - { - base_url = format!("{base_url}/backend-api"); - } - base_url -} - -fn codex_apps_mcp_url_for_base_url(base_url: &str) -> String { - let base_url = normalize_codex_apps_base_url(base_url); - if base_url.contains("/backend-api") { - format!("{base_url}/wham/apps") - } else if base_url.contains("/api/codex") { - format!("{base_url}/apps") - } else { - format!("{base_url}/api/codex/apps") - } -} - -pub(crate) fn codex_apps_mcp_url(config: &McpConfig) -> String { - codex_apps_mcp_url_for_base_url(&config.chatgpt_base_url) -} - -fn codex_apps_mcp_server_config(config: &McpConfig, auth: Option<&CodexAuth>) -> McpServerConfig { - let bearer_token_env_var = codex_apps_mcp_bearer_token_env_var(); - let http_headers = if bearer_token_env_var.is_some() { - None - } else { - codex_apps_mcp_http_headers(auth) - }; - let url = codex_apps_mcp_url(config); - - McpServerConfig { - transport: McpServerTransportConfig::StreamableHttp { - url, - bearer_token_env_var, - http_headers, - env_http_headers: None, - }, - experimental_environment: None, - enabled: true, - required: false, - supports_parallel_tool_calls: false, - disabled_reason: None, - startup_timeout_sec: Some(Duration::from_secs(30)), - tool_timeout_sec: None, - default_tools_approval_mode: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - tools: HashMap::new(), - } -} - pub fn with_codex_apps_mcp( mut servers: HashMap, auth: Option<&CodexAuth>, config: &McpConfig, ) -> HashMap { - if config.apps_enabled && auth.is_some_and(CodexAuth::is_chatgpt_auth) { + if config.apps_enabled && auth.is_some_and(CodexAuth::uses_codex_backend) { servers.insert( CODEX_APPS_MCP_SERVER_NAME.to_string(), - codex_apps_mcp_server_config(config, auth), + codex_apps_mcp_server_config(config), ); } else { servers.remove(CODEX_APPS_MCP_SERVER_NAME); @@ -329,8 +219,12 @@ pub async fn read_mcp_resource( ) -> anyhow::Result { let mut mcp_servers = effective_mcp_servers(config, auth); mcp_servers.retain(|name, _| name == server); - let auth_statuses = - compute_auth_statuses(mcp_servers.iter(), config.mcp_oauth_credentials_store_mode).await; + let auth_statuses = compute_auth_statuses( + mcp_servers.iter(), + config.mcp_oauth_credentials_store_mode, + auth, + ) + .await; let (tx_event, rx_event) = unbounded(); drop(rx_event); let (manager, cancel_token) = McpConnectionManager::new( @@ -340,11 +234,12 @@ pub async fn read_mcp_resource( &config.approval_policy, String::new(), tx_event, - SandboxPolicy::new_read_only_policy(), + PermissionProfile::default(), runtime_environment, config.codex_home.clone(), codex_apps_tools_cache_key(auth), tool_plugin_provenance(config), + auth, ) .await; @@ -361,73 +256,6 @@ pub async fn read_mcp_resource( result } -pub async fn collect_mcp_snapshot( - config: &McpConfig, - auth: Option<&CodexAuth>, - submit_id: String, - runtime_environment: McpRuntimeEnvironment, -) -> McpListToolsResponseEvent { - collect_mcp_snapshot_with_detail( - config, - auth, - submit_id, - runtime_environment, - McpSnapshotDetail::Full, - ) - .await -} - -pub async fn collect_mcp_snapshot_with_detail( - config: &McpConfig, - auth: Option<&CodexAuth>, - submit_id: String, - runtime_environment: McpRuntimeEnvironment, - detail: McpSnapshotDetail, -) -> McpListToolsResponseEvent { - let mcp_servers = effective_mcp_servers(config, auth); - let tool_plugin_provenance = tool_plugin_provenance(config); - if mcp_servers.is_empty() { - return McpListToolsResponseEvent { - tools: HashMap::new(), - resources: HashMap::new(), - resource_templates: HashMap::new(), - auth_statuses: HashMap::new(), - }; - } - - let auth_status_entries = - compute_auth_statuses(mcp_servers.iter(), config.mcp_oauth_credentials_store_mode).await; - - let (tx_event, rx_event) = unbounded(); - drop(rx_event); - - let (mcp_connection_manager, cancel_token) = McpConnectionManager::new( - &mcp_servers, - config.mcp_oauth_credentials_store_mode, - auth_status_entries.clone(), - &config.approval_policy, - submit_id, - tx_event, - SandboxPolicy::new_read_only_policy(), - runtime_environment, - config.codex_home.clone(), - codex_apps_tools_cache_key(auth), - tool_plugin_provenance, - ) - .await; - - let snapshot = collect_mcp_snapshot_from_manager_with_detail( - &mcp_connection_manager, - auth_status_entries, - detail, - ) - .await; - - cancel_token.cancel(); - - snapshot -} - #[derive(Debug, Clone)] pub struct McpServerStatusSnapshot { pub tools_by_server: HashMap>, @@ -436,22 +264,6 @@ pub struct McpServerStatusSnapshot { pub auth_statuses: HashMap, } -pub async fn collect_mcp_server_status_snapshot( - config: &McpConfig, - auth: Option<&CodexAuth>, - submit_id: String, - runtime_environment: McpRuntimeEnvironment, -) -> McpServerStatusSnapshot { - collect_mcp_server_status_snapshot_with_detail( - config, - auth, - submit_id, - runtime_environment, - McpSnapshotDetail::Full, - ) - .await -} - pub async fn collect_mcp_server_status_snapshot_with_detail( config: &McpConfig, auth: Option<&CodexAuth>, @@ -470,8 +282,12 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( }; } - let auth_status_entries = - compute_auth_statuses(mcp_servers.iter(), config.mcp_oauth_credentials_store_mode).await; + let auth_status_entries = compute_auth_statuses( + mcp_servers.iter(), + config.mcp_oauth_credentials_store_mode, + auth, + ) + .await; let (tx_event, rx_event) = unbounded(); drop(rx_event); @@ -483,11 +299,12 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( &config.approval_policy, submit_id, tx_event, - SandboxPolicy::new_read_only_policy(), + PermissionProfile::default(), runtime_environment, config.codex_home.clone(), codex_apps_tools_cache_key(auth), tool_plugin_provenance, + auth, ) .await; @@ -503,33 +320,97 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( snapshot } -pub fn split_qualified_tool_name(qualified_name: &str) -> Option<(String, String)> { - let mut parts = qualified_name.split(MCP_TOOL_NAME_DELIMITER); - let prefix = parts.next()?; - if prefix != MCP_TOOL_NAME_PREFIX { - return None; - } - let server_name = parts.next()?; - let tool_name: String = parts.collect::>().join(MCP_TOOL_NAME_DELIMITER); - if tool_name.is_empty() { - return None; - } - Some((server_name.to_string(), tool_name)) +pub async fn collect_mcp_snapshot_from_manager( + mcp_connection_manager: &McpConnectionManager, + auth_status_entries: HashMap, +) -> McpListToolsResponseEvent { + collect_mcp_snapshot_from_manager_with_detail( + mcp_connection_manager, + auth_status_entries, + McpSnapshotDetail::Full, + ) + .await } -pub fn group_tools_by_server( - tools: &HashMap, -) -> HashMap> { - let mut grouped = HashMap::new(); - for (qualified_name, tool) in tools { - if let Some((server_name, tool_name)) = split_qualified_tool_name(qualified_name) { - grouped - .entry(server_name) - .or_insert_with(HashMap::new) - .insert(tool_name, tool.clone()); +pub(crate) fn codex_apps_mcp_url(config: &McpConfig) -> String { + codex_apps_mcp_url_for_base_url(&config.chatgpt_base_url) +} + +/// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`. +/// MCP server/tool names are user-controlled, so sanitize the fully-qualified +/// name we expose to the model by replacing any disallowed character with `_`. +pub(crate) fn sanitize_responses_api_tool_name(name: &str) -> String { + let mut sanitized = String::with_capacity(name.len()); + for c in name.chars() { + if c.is_ascii_alphanumeric() || c == '_' { + sanitized.push(c); + } else { + sanitized.push('_'); } } - grouped + + if sanitized.is_empty() { + "_".to_string() + } else { + sanitized + } +} + +fn codex_apps_mcp_bearer_token_env_var() -> Option { + match env::var(CODEX_CONNECTORS_TOKEN_ENV_VAR) { + Ok(value) if !value.trim().is_empty() => Some(CODEX_CONNECTORS_TOKEN_ENV_VAR.to_string()), + Ok(_) => None, + Err(env::VarError::NotPresent) => None, + Err(env::VarError::NotUnicode(_)) => Some(CODEX_CONNECTORS_TOKEN_ENV_VAR.to_string()), + } +} + +fn normalize_codex_apps_base_url(base_url: &str) -> String { + let mut base_url = base_url.trim_end_matches('/').to_string(); + if (base_url.starts_with("https://chatgpt.com") + || base_url.starts_with("https://chat.openai.com")) + && !base_url.contains("/backend-api") + { + base_url = format!("{base_url}/backend-api"); + } + base_url +} + +fn codex_apps_mcp_url_for_base_url(base_url: &str) -> String { + let base_url = normalize_codex_apps_base_url(base_url); + if base_url.contains("/backend-api") { + format!("{base_url}/wham/apps") + } else if base_url.contains("/api/codex") { + format!("{base_url}/apps") + } else { + format!("{base_url}/api/codex/apps") + } +} + +fn codex_apps_mcp_server_config(config: &McpConfig) -> McpServerConfig { + let url = codex_apps_mcp_url(config); + + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var: codex_apps_mcp_bearer_token_env_var(), + http_headers: None, + env_http_headers: None, + }, + experimental_environment: None, + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: Some(Duration::from_secs(30)), + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + tools: HashMap::new(), + } } fn protocol_tool_from_rmcp_tool(name: &str, tool: &rmcp::model::Tool) -> Option { @@ -680,19 +561,7 @@ async fn collect_mcp_server_status_snapshot_from_manager( } } -pub async fn collect_mcp_snapshot_from_manager( - mcp_connection_manager: &McpConnectionManager, - auth_status_entries: HashMap, -) -> McpListToolsResponseEvent { - collect_mcp_snapshot_from_manager_with_detail( - mcp_connection_manager, - auth_status_entries, - McpSnapshotDetail::Full, - ) - .await -} - -pub async fn collect_mcp_snapshot_from_manager_with_detail( +async fn collect_mcp_snapshot_from_manager_with_detail( mcp_connection_manager: &McpConnectionManager, auth_status_entries: HashMap, detail: McpSnapshotDetail, diff --git a/codex-rs/codex-mcp/src/mcp/mod_tests.rs b/codex-rs/codex-mcp/src/mcp/mod_tests.rs index 8db52a9d83..8c977c63ce 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -3,6 +3,9 @@ use codex_config::Constrained; use codex_login::CodexAuth; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; +use codex_protocol::models::ManagedFileSystemPermissions; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use pretty_assertions::assert_eq; use std::collections::HashMap; @@ -25,27 +28,6 @@ fn test_mcp_config(codex_home: PathBuf) -> McpConfig { } } -fn make_tool(name: &str) -> Tool { - Tool { - name: name.to_string(), - title: None, - description: None, - input_schema: serde_json::json!({"type": "object", "properties": {}}), - output_schema: None, - annotations: None, - icons: None, - meta: None, - } -} - -#[test] -fn split_qualified_tool_name_returns_server_and_tool() { - assert_eq!( - split_qualified_tool_name("mcp__alpha__do_thing"), - Some(("alpha".to_string(), "do_thing".to_string())) - ); -} - #[test] fn qualified_mcp_tool_name_prefix_sanitizes_server_names_without_lowercasing() { assert_eq!( @@ -55,33 +37,32 @@ fn qualified_mcp_tool_name_prefix_sanitizes_server_names_without_lowercasing() { } #[test] -fn split_qualified_tool_name_rejects_invalid_names() { - assert_eq!(split_qualified_tool_name("other__alpha__do_thing"), None); - assert_eq!(split_qualified_tool_name("mcp__alpha__"), None); -} - -#[test] -fn group_tools_by_server_strips_prefix_and_groups() { - let mut tools = HashMap::new(); - tools.insert("mcp__alpha__do_thing".to_string(), make_tool("do_thing")); - tools.insert( - "mcp__alpha__nested__op".to_string(), - make_tool("nested__op"), - ); - tools.insert("mcp__beta__do_other".to_string(), make_tool("do_other")); - - let mut expected_alpha = HashMap::new(); - expected_alpha.insert("do_thing".to_string(), make_tool("do_thing")); - expected_alpha.insert("nested__op".to_string(), make_tool("nested__op")); - - let mut expected_beta = HashMap::new(); - expected_beta.insert("do_other".to_string(), make_tool("do_other")); - - let mut expected = HashMap::new(); - expected.insert("alpha".to_string(), expected_alpha); - expected.insert("beta".to_string(), expected_beta); - - assert_eq!(group_tools_by_server(&tools), expected); +fn mcp_prompt_auto_approval_honors_unrestricted_managed_profiles() { + assert!(mcp_permission_prompt_is_auto_approved( + AskForApproval::Never, + &PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Unrestricted, + network: NetworkSandboxPolicy::Enabled, + }, + )); + assert!(mcp_permission_prompt_is_auto_approved( + AskForApproval::Never, + &PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Unrestricted, + network: NetworkSandboxPolicy::Restricted, + }, + )); + assert!(!mcp_permission_prompt_is_auto_approved( + AskForApproval::Never, + &PermissionProfile::read_only(), + )); + assert!(!mcp_permission_prompt_is_auto_approved( + AskForApproval::OnRequest, + &PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Unrestricted, + network: NetworkSandboxPolicy::Enabled, + }, + )); } #[test] diff --git a/codex-rs/codex-mcp/src/mcp/skill_dependencies.rs b/codex-rs/codex-mcp/src/mcp/skill_dependencies.rs deleted file mode 100644 index f785fe4bde..0000000000 --- a/codex-rs/codex-mcp/src/mcp/skill_dependencies.rs +++ /dev/null @@ -1,172 +0,0 @@ -use std::collections::HashMap; -use std::collections::HashSet; - -use codex_config::McpServerConfig; -use codex_config::McpServerTransportConfig; -use codex_protocol::protocol::SkillMetadata; -use codex_protocol::protocol::SkillToolDependency; -use tracing::warn; - -pub fn collect_missing_mcp_dependencies( - mentioned_skills: &[SkillMetadata], - installed: &HashMap, -) -> HashMap { - let mut missing = HashMap::new(); - let installed_keys: HashSet = installed - .iter() - .map(|(name, config)| canonical_mcp_server_key(name, config)) - .collect(); - let mut seen_canonical_keys = HashSet::new(); - - for skill in mentioned_skills { - let Some(dependencies) = skill.dependencies.as_ref() else { - continue; - }; - - for tool in &dependencies.tools { - if !tool.r#type.eq_ignore_ascii_case("mcp") { - continue; - } - let dependency_key = match canonical_mcp_dependency_key(tool) { - Ok(key) => key, - Err(err) => { - let dependency = tool.value.as_str(); - let skill_name = skill.name.as_str(); - warn!( - "unable to auto-install MCP dependency {dependency} for skill {skill_name}: {err}", - ); - continue; - } - }; - if installed_keys.contains(&dependency_key) - || seen_canonical_keys.contains(&dependency_key) - { - continue; - } - - let config = match mcp_dependency_to_server_config(tool) { - Ok(config) => config, - Err(err) => { - let dependency = dependency_key.as_str(); - let skill_name = skill.name.as_str(); - warn!( - "unable to auto-install MCP dependency {dependency} for skill {skill_name}: {err}", - ); - continue; - } - }; - - missing.insert(tool.value.clone(), config); - seen_canonical_keys.insert(dependency_key); - } - } - - missing -} - -fn canonical_mcp_key(transport: &str, identifier: &str, fallback: &str) -> String { - let identifier = identifier.trim(); - if identifier.is_empty() { - fallback.to_string() - } else { - format!("mcp__{transport}__{identifier}") - } -} - -pub fn canonical_mcp_server_key(name: &str, config: &McpServerConfig) -> String { - match &config.transport { - McpServerTransportConfig::Stdio { command, .. } => { - canonical_mcp_key("stdio", command, name) - } - McpServerTransportConfig::StreamableHttp { url, .. } => { - canonical_mcp_key("streamable_http", url, name) - } - } -} - -fn canonical_mcp_dependency_key(dependency: &SkillToolDependency) -> Result { - let transport = dependency.transport.as_deref().unwrap_or("streamable_http"); - if transport.eq_ignore_ascii_case("streamable_http") { - let url = dependency - .url - .as_ref() - .ok_or_else(|| "missing url for streamable_http dependency".to_string())?; - return Ok(canonical_mcp_key("streamable_http", url, &dependency.value)); - } - if transport.eq_ignore_ascii_case("stdio") { - let command = dependency - .command - .as_ref() - .ok_or_else(|| "missing command for stdio dependency".to_string())?; - return Ok(canonical_mcp_key("stdio", command, &dependency.value)); - } - Err(format!("unsupported transport {transport}")) -} - -fn mcp_dependency_to_server_config( - dependency: &SkillToolDependency, -) -> Result { - let transport = dependency.transport.as_deref().unwrap_or("streamable_http"); - if transport.eq_ignore_ascii_case("streamable_http") { - let url = dependency - .url - .as_ref() - .ok_or_else(|| "missing url for streamable_http dependency".to_string())?; - return Ok(McpServerConfig { - transport: McpServerTransportConfig::StreamableHttp { - url: url.clone(), - bearer_token_env_var: None, - http_headers: None, - env_http_headers: None, - }, - experimental_environment: None, - enabled: true, - required: false, - supports_parallel_tool_calls: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - default_tools_approval_mode: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - tools: HashMap::new(), - }); - } - - if transport.eq_ignore_ascii_case("stdio") { - let command = dependency - .command - .as_ref() - .ok_or_else(|| "missing command for stdio dependency".to_string())?; - return Ok(McpServerConfig { - transport: McpServerTransportConfig::Stdio { - command: command.clone(), - args: Vec::new(), - env: None, - env_vars: Vec::new(), - cwd: None, - }, - experimental_environment: None, - enabled: true, - required: false, - supports_parallel_tool_calls: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - default_tools_approval_mode: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - tools: HashMap::new(), - }); - } - - Err(format!("unsupported transport {transport}")) -} - -#[cfg(test)] -#[path = "skill_dependencies_tests.rs"] -mod tests; diff --git a/codex-rs/codex-mcp/src/mcp/skill_dependencies_tests.rs b/codex-rs/codex-mcp/src/mcp/skill_dependencies_tests.rs deleted file mode 100644 index 2d8390d15e..0000000000 --- a/codex-rs/codex-mcp/src/mcp/skill_dependencies_tests.rs +++ /dev/null @@ -1,115 +0,0 @@ -use super::*; -use codex_protocol::protocol::SkillDependencies; -use codex_protocol::protocol::SkillMetadata; -use codex_protocol::protocol::SkillScope; -use codex_utils_absolute_path::test_support::PathBufExt as _; -use codex_utils_absolute_path::test_support::test_path_buf; -use pretty_assertions::assert_eq; - -fn skill_with_tools(tools: Vec) -> SkillMetadata { - SkillMetadata { - name: "skill".to_string(), - description: "skill".to_string(), - short_description: None, - interface: None, - dependencies: Some(SkillDependencies { tools }), - path: test_path_buf("/tmp/skill").abs(), - scope: SkillScope::User, - enabled: true, - } -} - -#[test] -fn collect_missing_respects_canonical_installed_key() { - let url = "https://example.com/mcp".to_string(); - let skills = vec![skill_with_tools(vec![SkillToolDependency { - r#type: "mcp".to_string(), - value: "github".to_string(), - description: None, - transport: Some("streamable_http".to_string()), - command: None, - url: Some(url.clone()), - }])]; - let installed = HashMap::from([( - "alias".to_string(), - McpServerConfig { - transport: McpServerTransportConfig::StreamableHttp { - url, - bearer_token_env_var: None, - http_headers: None, - env_http_headers: None, - }, - experimental_environment: None, - enabled: true, - required: false, - supports_parallel_tool_calls: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - default_tools_approval_mode: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - tools: HashMap::new(), - }, - )]); - - assert_eq!( - collect_missing_mcp_dependencies(&skills, &installed), - HashMap::new() - ); -} - -#[test] -fn collect_missing_dedupes_by_canonical_key_but_preserves_original_name() { - let url = "https://example.com/one".to_string(); - let skills = vec![skill_with_tools(vec![ - SkillToolDependency { - r#type: "mcp".to_string(), - value: "alias-one".to_string(), - description: None, - transport: Some("streamable_http".to_string()), - command: None, - url: Some(url.clone()), - }, - SkillToolDependency { - r#type: "mcp".to_string(), - value: "alias-two".to_string(), - description: None, - transport: Some("streamable_http".to_string()), - command: None, - url: Some(url.clone()), - }, - ])]; - - let expected = HashMap::from([( - "alias-one".to_string(), - McpServerConfig { - transport: McpServerTransportConfig::StreamableHttp { - url, - bearer_token_env_var: None, - http_headers: None, - env_http_headers: None, - }, - experimental_environment: None, - enabled: true, - required: false, - supports_parallel_tool_calls: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - default_tools_approval_mode: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - tools: HashMap::new(), - }, - )]); - - assert_eq!( - collect_missing_mcp_dependencies(&skills, &HashMap::new()), - expected - ); -} diff --git a/codex-rs/codex-mcp/src/mcp_connection_manager.rs b/codex-rs/codex-mcp/src/mcp_connection_manager.rs deleted file mode 100644 index 10c48e040e..0000000000 --- a/codex-rs/codex-mcp/src/mcp_connection_manager.rs +++ /dev/null @@ -1,1870 +0,0 @@ -//! Connection manager for Model Context Protocol (MCP) servers. -//! -//! The [`McpConnectionManager`] owns one [`codex_rmcp_client::RmcpClient`] per -//! configured server (keyed by the *server name*). It offers convenience -//! helpers to query the available tools across *all* servers and returns them -//! in a single aggregated map using the model-visible fully-qualified tool name -//! as the key. - -use std::borrow::Cow; -use std::collections::HashMap; -use std::collections::HashSet; -use std::env; -use std::ffi::OsString; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::Mutex as StdMutex; -use std::sync::atomic::AtomicBool; -use std::sync::atomic::Ordering; -use std::time::Duration; -use std::time::Instant; - -use crate::McpAuthStatusEntry; -use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; -use crate::mcp::McpConfig; -use crate::mcp::ToolPluginProvenance; -use crate::mcp::configured_mcp_servers; -use crate::mcp::effective_mcp_servers; -use crate::mcp::mcp_permission_prompt_is_auto_approved; -use crate::mcp::tool_plugin_provenance; -pub(crate) use crate::mcp_tool_names::qualify_tools; -use anyhow::Context; -use anyhow::Result; -use anyhow::anyhow; -use async_channel::Sender; -use codex_async_utils::CancelErr; -use codex_async_utils::OrCancelExt; -use codex_config::Constrained; -use codex_config::types::OAuthCredentialsStoreMode; -use codex_exec_server::Environment; -use codex_protocol::ToolName; -use codex_protocol::approvals::ElicitationRequest; -use codex_protocol::approvals::ElicitationRequestEvent; -use codex_protocol::mcp::CallToolResult; -use codex_protocol::mcp::RequestId as ProtocolRequestId; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::Event; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::McpStartupCompleteEvent; -use codex_protocol::protocol::McpStartupFailure; -use codex_protocol::protocol::McpStartupStatus; -use codex_protocol::protocol::McpStartupUpdateEvent; -use codex_protocol::protocol::SandboxPolicy; -use codex_rmcp_client::ElicitationResponse; -use codex_rmcp_client::ExecutorStdioServerLauncher; -use codex_rmcp_client::LocalStdioServerLauncher; -use codex_rmcp_client::RmcpClient; -use codex_rmcp_client::SendElicitation; -use codex_rmcp_client::StdioServerLauncher; -use futures::future::BoxFuture; -use futures::future::FutureExt; -use futures::future::Shared; -use rmcp::model::ClientCapabilities; -use rmcp::model::CreateElicitationRequestParams; -use rmcp::model::ElicitationAction; -use rmcp::model::ElicitationCapability; -use rmcp::model::FormElicitationCapability; -use rmcp::model::Implementation; -use rmcp::model::InitializeRequestParams; -use rmcp::model::ListResourceTemplatesResult; -use rmcp::model::ListResourcesResult; -use rmcp::model::PaginatedRequestParams; -use rmcp::model::ProtocolVersion; -use rmcp::model::ReadResourceRequestParams; -use rmcp::model::ReadResourceResult; -use rmcp::model::RequestId; -use rmcp::model::Resource; -use rmcp::model::ResourceTemplate; -use rmcp::model::Tool; - -use serde::Deserialize; -use serde::Serialize; -use serde_json::Map; -use serde_json::Value as JsonValue; -use sha1::Digest; -use sha1::Sha1; -use tokio::sync::Mutex; -use tokio::sync::oneshot; -use tokio::task::JoinSet; -use tokio_util::sync::CancellationToken; -use tracing::instrument; -use tracing::warn; -use url::Url; - -use codex_config::McpServerConfig; -use codex_config::McpServerTransportConfig; -use codex_login::CodexAuth; -use codex_utils_plugins::mcp_connector::is_connector_id_allowed; -use codex_utils_plugins::mcp_connector::sanitize_name; - -/// Delimiter used to separate MCP tool-name parts. -const MCP_TOOL_NAME_DELIMITER: &str = "__"; - -/// Default timeout for initializing MCP server & initially listing tools. -pub const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(30); - -/// Default timeout for individual tool calls. -const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(120); - -const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 2; -const CODEX_APPS_TOOLS_CACHE_DIR: &str = "cache/codex_apps_tools"; -const MCP_TOOLS_LIST_DURATION_METRIC: &str = "codex.mcp.tools.list.duration_ms"; -const MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC: &str = "codex.mcp.tools.fetch_uncached.duration_ms"; -const MCP_TOOLS_CACHE_WRITE_DURATION_METRIC: &str = "codex.mcp.tools.cache_write.duration_ms"; - -fn sha1_hex(s: &str) -> String { - let mut hasher = Sha1::new(); - hasher.update(s.as_bytes()); - let sha1 = hasher.finalize(); - format!("{sha1:x}") -} - -pub fn codex_apps_tools_cache_key(auth: Option<&CodexAuth>) -> CodexAppsToolsCacheKey { - let token_data = auth.and_then(|auth| auth.get_token_data().ok()); - let account_id = token_data - .as_ref() - .and_then(|token_data| token_data.account_id.clone()); - let chatgpt_user_id = token_data - .as_ref() - .and_then(|token_data| token_data.id_token.chatgpt_user_id.clone()); - let is_workspace_account = token_data - .as_ref() - .is_some_and(|token_data| token_data.id_token.is_workspace_account()); - - CodexAppsToolsCacheKey { - account_id, - chatgpt_user_id, - is_workspace_account, - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolInfo { - /// Raw MCP server name used for routing the tool call. - pub server_name: String, - /// Model-visible tool name used in Responses API tool declarations. - #[serde(rename = "tool_name", alias = "callable_name")] - pub callable_name: String, - /// Model-visible namespace used for deferred tool loading. - #[serde(rename = "tool_namespace", alias = "callable_namespace")] - pub callable_namespace: String, - /// Instructions from the MCP server initialize result. - #[serde(default)] - pub server_instructions: Option, - /// Raw MCP tool definition; `tool.name` is sent back to the MCP server. - pub tool: Tool, - pub connector_id: Option, - pub connector_name: Option, - #[serde(default)] - pub plugin_display_names: Vec, - pub connector_description: Option, -} - -impl ToolInfo { - pub fn canonical_tool_name(&self) -> ToolName { - ToolName::namespaced(self.callable_namespace.clone(), self.callable_name.clone()) - } -} - -const META_OPENAI_FILE_PARAMS: &str = "openai/fileParams"; - -pub fn declared_openai_file_input_param_names( - meta: Option<&Map>, -) -> Vec { - let Some(meta) = meta else { - return Vec::new(); - }; - - meta.get(META_OPENAI_FILE_PARAMS) - .and_then(JsonValue::as_array) - .into_iter() - .flatten() - .filter_map(JsonValue::as_str) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .collect() -} - -/// Returns the model-visible view of a tool while preserving the raw metadata -/// used by execution. Keep cache entries raw and call this at manager return -/// boundaries. -fn tool_with_model_visible_input_schema(tool: &Tool) -> Tool { - let file_params = declared_openai_file_input_param_names(tool.meta.as_deref()); - if file_params.is_empty() { - return tool.clone(); - } - - let mut tool = tool.clone(); - let mut input_schema = JsonValue::Object(tool.input_schema.as_ref().clone()); - mask_input_schema_for_file_path_params(&mut input_schema, &file_params); - if let JsonValue::Object(input_schema) = input_schema { - tool.input_schema = Arc::new(input_schema); - } - tool -} - -fn mask_input_schema_for_file_path_params(input_schema: &mut JsonValue, file_params: &[String]) { - let Some(properties) = input_schema - .as_object_mut() - .and_then(|schema| schema.get_mut("properties")) - .and_then(JsonValue::as_object_mut) - else { - return; - }; - - for field_name in file_params { - let Some(property_schema) = properties.get_mut(field_name) else { - continue; - }; - mask_input_property_schema(property_schema); - } -} - -fn mask_input_property_schema(schema: &mut JsonValue) { - let Some(object) = schema.as_object_mut() else { - return; - }; - - let mut description = object - .get("description") - .and_then(JsonValue::as_str) - .map(str::to_string) - .unwrap_or_default(); - let guidance = "This parameter expects an absolute local file path. If you want to upload a file, provide the absolute path to that file here."; - if description.is_empty() { - description = guidance.to_string(); - } else if !description.contains(guidance) { - description = format!("{description} {guidance}"); - } - - let is_array = object.get("type").and_then(JsonValue::as_str) == Some("array") - || object.get("items").is_some(); - object.clear(); - object.insert("description".to_string(), JsonValue::String(description)); - if is_array { - object.insert("type".to_string(), JsonValue::String("array".to_string())); - object.insert("items".to_string(), serde_json::json!({ "type": "string" })); - } else { - object.insert("type".to_string(), JsonValue::String("string".to_string())); - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CodexAppsToolsCacheKey { - account_id: Option, - chatgpt_user_id: Option, - is_workspace_account: bool, -} - -#[derive(Clone)] -struct CodexAppsToolsCacheContext { - codex_home: PathBuf, - user_key: CodexAppsToolsCacheKey, -} - -impl CodexAppsToolsCacheContext { - fn cache_path(&self) -> PathBuf { - let user_key_json = serde_json::to_string(&self.user_key).unwrap_or_default(); - let user_key_hash = sha1_hex(&user_key_json); - self.codex_home - .join(CODEX_APPS_TOOLS_CACHE_DIR) - .join(format!("{user_key_hash}.json")) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct CodexAppsToolsDiskCache { - schema_version: u8, - tools: Vec, -} - -enum CachedCodexAppsToolsLoad { - Hit(Vec), - Missing, - Invalid, -} - -type ResponderMap = HashMap<(String, RequestId), oneshot::Sender>; - -fn elicitation_is_rejected_by_policy(approval_policy: AskForApproval) -> bool { - match approval_policy { - AskForApproval::Never => true, - AskForApproval::OnFailure => false, - AskForApproval::OnRequest => false, - AskForApproval::UnlessTrusted => false, - AskForApproval::Granular(granular_config) => !granular_config.allows_mcp_elicitations(), - } -} - -fn can_auto_accept_elicitation(elicitation: &CreateElicitationRequestParams) -> bool { - match elicitation { - CreateElicitationRequestParams::FormElicitationParams { - requested_schema, .. - } => { - // Auto-accept confirm/approval elicitations without schema requirements. - requested_schema.properties.is_empty() - } - CreateElicitationRequestParams::UrlElicitationParams { .. } => false, - } -} - -#[derive(Clone)] -struct ElicitationRequestManager { - requests: Arc>, - approval_policy: Arc>, - sandbox_policy: Arc>, -} - -impl ElicitationRequestManager { - fn new(approval_policy: AskForApproval, sandbox_policy: SandboxPolicy) -> Self { - Self { - requests: Arc::new(Mutex::new(HashMap::new())), - approval_policy: Arc::new(StdMutex::new(approval_policy)), - sandbox_policy: Arc::new(StdMutex::new(sandbox_policy)), - } - } - - async fn resolve( - &self, - server_name: String, - id: RequestId, - response: ElicitationResponse, - ) -> Result<()> { - self.requests - .lock() - .await - .remove(&(server_name, id)) - .ok_or_else(|| anyhow!("elicitation request not found"))? - .send(response) - .map_err(|e| anyhow!("failed to send elicitation response: {e:?}")) - } - - fn make_sender(&self, server_name: String, tx_event: Sender) -> SendElicitation { - let elicitation_requests = self.requests.clone(); - let approval_policy = self.approval_policy.clone(); - let sandbox_policy = self.sandbox_policy.clone(); - Box::new(move |id, elicitation| { - let elicitation_requests = elicitation_requests.clone(); - let tx_event = tx_event.clone(); - let server_name = server_name.clone(); - let approval_policy = approval_policy.clone(); - let sandbox_policy = sandbox_policy.clone(); - async move { - let approval_policy = approval_policy - .lock() - .map(|policy| *policy) - .unwrap_or(AskForApproval::Never); - let sandbox_policy = sandbox_policy - .lock() - .map(|policy| policy.clone()) - .unwrap_or_else(|_| SandboxPolicy::new_read_only_policy()); - if mcp_permission_prompt_is_auto_approved(approval_policy, &sandbox_policy) - && can_auto_accept_elicitation(&elicitation) - { - return Ok(ElicitationResponse { - action: ElicitationAction::Accept, - content: Some(serde_json::json!({})), - meta: None, - }); - } - - if elicitation_is_rejected_by_policy(approval_policy) { - return Ok(ElicitationResponse { - action: ElicitationAction::Decline, - content: None, - meta: None, - }); - } - - let request = match elicitation { - CreateElicitationRequestParams::FormElicitationParams { - meta, - message, - requested_schema, - } => ElicitationRequest::Form { - meta: meta - .map(serde_json::to_value) - .transpose() - .context("failed to serialize MCP elicitation metadata")?, - message, - requested_schema: serde_json::to_value(requested_schema) - .context("failed to serialize MCP elicitation schema")?, - }, - CreateElicitationRequestParams::UrlElicitationParams { - meta, - message, - url, - elicitation_id, - } => ElicitationRequest::Url { - meta: meta - .map(serde_json::to_value) - .transpose() - .context("failed to serialize MCP elicitation metadata")?, - message, - url, - elicitation_id, - }, - }; - let (tx, rx) = oneshot::channel(); - { - let mut lock = elicitation_requests.lock().await; - lock.insert((server_name.clone(), id.clone()), tx); - } - let _ = tx_event - .send(Event { - id: "mcp_elicitation_request".to_string(), - msg: EventMsg::ElicitationRequest(ElicitationRequestEvent { - turn_id: None, - server_name, - id: match id.clone() { - rmcp::model::NumberOrString::String(value) => { - ProtocolRequestId::String(value.to_string()) - } - rmcp::model::NumberOrString::Number(value) => { - ProtocolRequestId::Integer(value) - } - }, - request, - }), - }) - .await; - rx.await - .context("elicitation request channel closed unexpectedly") - } - .boxed() - }) - } -} - -#[derive(Clone)] -struct ManagedClient { - client: Arc, - tools: Vec, - tool_filter: ToolFilter, - tool_timeout: Option, - server_instructions: Option, - server_supports_sandbox_state_meta_capability: bool, - codex_apps_tools_cache_context: Option, -} - -impl ManagedClient { - fn listed_tools(&self) -> Vec { - let total_start = Instant::now(); - if let Some(cache_context) = self.codex_apps_tools_cache_context.as_ref() - && let CachedCodexAppsToolsLoad::Hit(tools) = - load_cached_codex_apps_tools(cache_context) - { - emit_duration( - MCP_TOOLS_LIST_DURATION_METRIC, - total_start.elapsed(), - &[("cache", "hit")], - ); - return filter_tools(tools, &self.tool_filter); - } - - if self.codex_apps_tools_cache_context.is_some() { - emit_duration( - MCP_TOOLS_LIST_DURATION_METRIC, - total_start.elapsed(), - &[("cache", "miss")], - ); - } - - self.tools.clone() - } -} - -#[derive(Clone)] -struct AsyncManagedClient { - client: Shared>>, - startup_snapshot: Option>, - startup_complete: Arc, - tool_plugin_provenance: Arc, -} - -impl AsyncManagedClient { - // Keep this constructor flat so the startup inputs remain readable at the - // single call site instead of introducing a one-off params wrapper. - #[allow(clippy::too_many_arguments)] - fn new( - server_name: String, - config: McpServerConfig, - store_mode: OAuthCredentialsStoreMode, - cancel_token: CancellationToken, - tx_event: Sender, - elicitation_requests: ElicitationRequestManager, - codex_apps_tools_cache_context: Option, - tool_plugin_provenance: Arc, - runtime_environment: McpRuntimeEnvironment, - ) -> Self { - let tool_filter = ToolFilter::from_config(&config); - let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot( - &server_name, - codex_apps_tools_cache_context.as_ref(), - ) - .map(|tools| filter_tools(tools, &tool_filter)); - let startup_tool_filter = tool_filter; - let startup_complete = Arc::new(AtomicBool::new(false)); - let startup_complete_for_fut = Arc::clone(&startup_complete); - let fut = async move { - let outcome = async { - if let Err(error) = validate_mcp_server_name(&server_name) { - return Err(error.into()); - } - - let client = Arc::new( - make_rmcp_client( - &server_name, - config.clone(), - store_mode, - runtime_environment, - ) - .await?, - ); - match start_server_task( - server_name, - client, - StartServerTaskParams { - startup_timeout: config - .startup_timeout_sec - .or(Some(DEFAULT_STARTUP_TIMEOUT)), - tool_timeout: config.tool_timeout_sec.unwrap_or(DEFAULT_TOOL_TIMEOUT), - tool_filter: startup_tool_filter, - tx_event, - elicitation_requests, - codex_apps_tools_cache_context, - }, - ) - .or_cancel(&cancel_token) - .await - { - Ok(result) => result, - Err(CancelErr::Cancelled) => Err(StartupOutcomeError::Cancelled), - } - } - .await; - - startup_complete_for_fut.store(true, Ordering::Release); - outcome - }; - let client = fut.boxed().shared(); - if startup_snapshot.is_some() { - let startup_task = client.clone(); - tokio::spawn(async move { - let _ = startup_task.await; - }); - } - - Self { - client, - startup_snapshot, - startup_complete, - tool_plugin_provenance, - } - } - - async fn client(&self) -> Result { - self.client.clone().await - } - - fn startup_snapshot_while_initializing(&self) -> Option> { - if !self.startup_complete.load(Ordering::Acquire) { - return self.startup_snapshot.clone(); - } - None - } - - async fn listed_tools(&self) -> Option> { - let annotate_tools = |tools: Vec| { - let mut tools = tools; - for tool in &mut tools { - if tool.server_name == CODEX_APPS_MCP_SERVER_NAME { - tool.tool = tool_with_model_visible_input_schema(&tool.tool); - } - - let plugin_names = match tool.connector_id.as_deref() { - Some(connector_id) => self - .tool_plugin_provenance - .plugin_display_names_for_connector_id(connector_id), - None => self - .tool_plugin_provenance - .plugin_display_names_for_mcp_server_name(tool.server_name.as_str()), - }; - tool.plugin_display_names = plugin_names.to_vec(); - - if plugin_names.is_empty() { - continue; - } - - let plugin_source_note = if plugin_names.len() == 1 { - format!("This tool is part of plugin `{}`.", plugin_names[0]) - } else { - format!( - "This tool is part of plugins {}.", - plugin_names - .iter() - .map(|plugin_name| format!("`{plugin_name}`")) - .collect::>() - .join(", ") - ) - }; - let description = tool - .tool - .description - .as_deref() - .map(str::trim) - .unwrap_or(""); - let annotated_description = if description.is_empty() { - plugin_source_note - } else if matches!(description.chars().last(), Some('.' | '!' | '?')) { - format!("{description} {plugin_source_note}") - } else { - format!("{description}. {plugin_source_note}") - }; - tool.tool.description = Some(Cow::Owned(annotated_description)); - } - tools - }; - - // Keep cache payloads raw; plugin provenance is resolved per-session at read time. - let tools = if let Some(startup_tools) = self.startup_snapshot_while_initializing() { - Some(startup_tools) - } else { - match self.client().await { - Ok(client) => Some(client.listed_tools()), - Err(_) => self.startup_snapshot.clone(), - } - }; - tools.map(annotate_tools) - } -} - -/// MCP server capability indicating that Codex should include [`SandboxState`] -/// in tool-call request `_meta` under this key. -pub const MCP_SANDBOX_STATE_META_CAPABILITY: &str = "codex/sandbox-state-meta"; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SandboxState { - pub sandbox_policy: SandboxPolicy, - pub codex_linux_sandbox_exe: Option, - pub sandbox_cwd: PathBuf, - #[serde(default)] - pub use_legacy_landlock: bool, -} - -/// A thin wrapper around a set of running [`RmcpClient`] instances. -pub struct McpConnectionManager { - clients: HashMap, - server_origins: HashMap, - elicitation_requests: ElicitationRequestManager, -} - -/// Runtime placement information used when starting MCP server transports. -/// -/// `McpConfig` describes what servers exist. This value describes where those -/// servers should run for the current caller. Keep it explicit at manager -/// construction time so status/snapshot paths and real sessions make the same -/// local-vs-remote decision. `fallback_cwd` is not a per-server override; it is -/// used only when an executor-backed stdio server omits `cwd` and the executor -/// API still needs a concrete process working directory. -#[derive(Clone)] -pub struct McpRuntimeEnvironment { - environment: Arc, - fallback_cwd: PathBuf, -} - -impl McpRuntimeEnvironment { - pub fn new(environment: Arc, fallback_cwd: PathBuf) -> Self { - Self { - environment, - fallback_cwd, - } - } - - fn environment(&self) -> Arc { - Arc::clone(&self.environment) - } - - fn fallback_cwd(&self) -> PathBuf { - self.fallback_cwd.clone() - } -} - -impl McpConnectionManager { - pub fn configured_servers(&self, config: &McpConfig) -> HashMap { - configured_mcp_servers(config) - } - - pub fn effective_servers( - &self, - config: &McpConfig, - auth: Option<&CodexAuth>, - ) -> HashMap { - effective_mcp_servers(config, auth) - } - - pub fn tool_plugin_provenance(&self, config: &McpConfig) -> ToolPluginProvenance { - tool_plugin_provenance(config) - } - - pub fn new_uninitialized( - approval_policy: &Constrained, - sandbox_policy: &Constrained, - ) -> Self { - Self { - clients: HashMap::new(), - server_origins: HashMap::new(), - elicitation_requests: ElicitationRequestManager::new( - approval_policy.value(), - sandbox_policy.get().clone(), - ), - } - } - - pub fn has_servers(&self) -> bool { - !self.clients.is_empty() - } - - pub fn server_origin(&self, server_name: &str) -> Option<&str> { - self.server_origins.get(server_name).map(String::as_str) - } - - pub fn set_approval_policy(&self, approval_policy: &Constrained) { - if let Ok(mut policy) = self.elicitation_requests.approval_policy.lock() { - *policy = approval_policy.value(); - } - } - - pub fn set_sandbox_policy(&self, sandbox_policy: &SandboxPolicy) { - if let Ok(mut policy) = self.elicitation_requests.sandbox_policy.lock() { - *policy = sandbox_policy.clone(); - } - } - - #[allow(clippy::new_ret_no_self, clippy::too_many_arguments)] - pub async fn new( - mcp_servers: &HashMap, - store_mode: OAuthCredentialsStoreMode, - auth_entries: HashMap, - approval_policy: &Constrained, - submit_id: String, - tx_event: Sender, - initial_sandbox_policy: SandboxPolicy, - runtime_environment: McpRuntimeEnvironment, - codex_home: PathBuf, - codex_apps_tools_cache_key: CodexAppsToolsCacheKey, - tool_plugin_provenance: ToolPluginProvenance, - ) -> (Self, CancellationToken) { - let cancel_token = CancellationToken::new(); - let mut clients = HashMap::new(); - let mut server_origins = HashMap::new(); - let mut join_set = JoinSet::new(); - let elicitation_requests = - ElicitationRequestManager::new(approval_policy.value(), initial_sandbox_policy); - let tool_plugin_provenance = Arc::new(tool_plugin_provenance); - let startup_submit_id = submit_id.clone(); - let mcp_servers = mcp_servers.clone(); - for (server_name, cfg) in mcp_servers.into_iter().filter(|(_, cfg)| cfg.enabled) { - if let Some(origin) = transport_origin(&cfg.transport) { - server_origins.insert(server_name.clone(), origin); - } - let cancel_token = cancel_token.child_token(); - let _ = emit_update( - startup_submit_id.as_str(), - &tx_event, - McpStartupUpdateEvent { - server: server_name.clone(), - status: McpStartupStatus::Starting, - }, - ) - .await; - let codex_apps_tools_cache_context = if server_name == CODEX_APPS_MCP_SERVER_NAME { - Some(CodexAppsToolsCacheContext { - codex_home: codex_home.clone(), - user_key: codex_apps_tools_cache_key.clone(), - }) - } else { - None - }; - let async_managed_client = AsyncManagedClient::new( - server_name.clone(), - cfg, - store_mode, - cancel_token.clone(), - tx_event.clone(), - elicitation_requests.clone(), - codex_apps_tools_cache_context, - Arc::clone(&tool_plugin_provenance), - runtime_environment.clone(), - ); - clients.insert(server_name.clone(), async_managed_client.clone()); - let tx_event = tx_event.clone(); - let submit_id = startup_submit_id.clone(); - let auth_entry = auth_entries.get(&server_name).cloned(); - join_set.spawn(async move { - let mut outcome = async_managed_client.client().await; - if cancel_token.is_cancelled() { - outcome = Err(StartupOutcomeError::Cancelled); - } - let status = match &outcome { - Ok(_) => McpStartupStatus::Ready, - Err(StartupOutcomeError::Cancelled) => McpStartupStatus::Cancelled, - Err(error) => { - let error_str = mcp_init_error_display( - server_name.as_str(), - auth_entry.as_ref(), - error, - ); - McpStartupStatus::Failed { error: error_str } - } - }; - - let _ = emit_update( - submit_id.as_str(), - &tx_event, - McpStartupUpdateEvent { - server: server_name.clone(), - status, - }, - ) - .await; - - (server_name, outcome) - }); - } - let manager = Self { - clients, - server_origins, - elicitation_requests: elicitation_requests.clone(), - }; - tokio::spawn(async move { - let outcomes = join_set.join_all().await; - let mut summary = McpStartupCompleteEvent::default(); - for (server_name, outcome) in outcomes { - match outcome { - Ok(_) => summary.ready.push(server_name), - Err(StartupOutcomeError::Cancelled) => summary.cancelled.push(server_name), - Err(StartupOutcomeError::Failed { error }) => { - summary.failed.push(McpStartupFailure { - server: server_name, - error, - }) - } - } - } - let _ = tx_event - .send(Event { - id: startup_submit_id, - msg: EventMsg::McpStartupComplete(summary), - }) - .await; - }); - (manager, cancel_token) - } - - async fn client_by_name(&self, name: &str) -> Result { - self.clients - .get(name) - .ok_or_else(|| anyhow!("unknown MCP server '{name}'"))? - .client() - .await - .context("failed to get client") - } - - pub async fn resolve_elicitation( - &self, - server_name: String, - id: RequestId, - response: ElicitationResponse, - ) -> Result<()> { - self.elicitation_requests - .resolve(server_name, id, response) - .await - } - - pub async fn wait_for_server_ready(&self, server_name: &str, timeout: Duration) -> bool { - let Some(async_managed_client) = self.clients.get(server_name) else { - return false; - }; - - match tokio::time::timeout(timeout, async_managed_client.client()).await { - Ok(Ok(_)) => true, - Ok(Err(_)) | Err(_) => false, - } - } - - pub async fn required_startup_failures( - &self, - required_servers: &[String], - ) -> Vec { - let mut failures = Vec::new(); - for server_name in required_servers { - let Some(async_managed_client) = self.clients.get(server_name).cloned() else { - failures.push(McpStartupFailure { - server: server_name.clone(), - error: format!("required MCP server `{server_name}` was not initialized"), - }); - continue; - }; - - match async_managed_client.client().await { - Ok(_) => {} - Err(error) => failures.push(McpStartupFailure { - server: server_name.clone(), - error: startup_outcome_error_message(error), - }), - } - } - failures - } - - /// Returns a single map that contains all tools. Each key is the - /// fully-qualified name for the tool. - #[instrument(level = "trace", skip_all)] - pub async fn list_all_tools(&self) -> HashMap { - let mut tools = Vec::new(); - for managed_client in self.clients.values() { - let Some(server_tools) = managed_client.listed_tools().await else { - continue; - }; - tools.extend(server_tools); - } - qualify_tools(tools) - } - - /// Force-refresh codex apps tools by bypassing the in-process cache. - /// - /// On success, the refreshed tools replace the cache contents and the - /// latest filtered tool map is returned directly to the caller. On - /// failure, the existing cache remains unchanged. - pub async fn hard_refresh_codex_apps_tools_cache(&self) -> Result> { - let managed_client = self - .clients - .get(CODEX_APPS_MCP_SERVER_NAME) - .ok_or_else(|| anyhow!("unknown MCP server '{CODEX_APPS_MCP_SERVER_NAME}'"))? - .client() - .await - .context("failed to get client")?; - - let list_start = Instant::now(); - let fetch_start = Instant::now(); - let tools = list_tools_for_client_uncached( - CODEX_APPS_MCP_SERVER_NAME, - &managed_client.client, - managed_client.tool_timeout, - managed_client.server_instructions.as_deref(), - ) - .await - .with_context(|| { - format!("failed to refresh tools for MCP server '{CODEX_APPS_MCP_SERVER_NAME}'") - })?; - emit_duration( - MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC, - fetch_start.elapsed(), - &[], - ); - - write_cached_codex_apps_tools_if_needed( - CODEX_APPS_MCP_SERVER_NAME, - managed_client.codex_apps_tools_cache_context.as_ref(), - &tools, - ); - emit_duration( - MCP_TOOLS_LIST_DURATION_METRIC, - list_start.elapsed(), - &[("cache", "miss")], - ); - let tools = filter_tools(tools, &managed_client.tool_filter) - .into_iter() - .map(|mut tool| { - tool.tool = tool_with_model_visible_input_schema(&tool.tool); - tool - }); - Ok(qualify_tools(tools)) - } - - /// Returns a single map that contains all resources. Each key is the - /// server name and the value is a vector of resources. - pub async fn list_all_resources(&self) -> HashMap> { - let mut join_set = JoinSet::new(); - - let clients_snapshot = &self.clients; - - for (server_name, async_managed_client) in clients_snapshot { - let server_name = server_name.clone(); - let Ok(managed_client) = async_managed_client.client().await else { - continue; - }; - let timeout = managed_client.tool_timeout; - let client = managed_client.client.clone(); - - join_set.spawn(async move { - let mut collected: Vec = Vec::new(); - let mut cursor: Option = None; - - loop { - let params = cursor.as_ref().map(|next| PaginatedRequestParams { - meta: None, - cursor: Some(next.clone()), - }); - let response = match client.list_resources(params, timeout).await { - Ok(result) => result, - Err(err) => return (server_name, Err(err)), - }; - - collected.extend(response.resources); - - match response.next_cursor { - Some(next) => { - if cursor.as_ref() == Some(&next) { - return ( - server_name, - Err(anyhow!("resources/list returned duplicate cursor")), - ); - } - cursor = Some(next); - } - None => return (server_name, Ok(collected)), - } - } - }); - } - - let mut aggregated: HashMap> = HashMap::new(); - - while let Some(join_res) = join_set.join_next().await { - match join_res { - Ok((server_name, Ok(resources))) => { - aggregated.insert(server_name, resources); - } - Ok((server_name, Err(err))) => { - warn!("Failed to list resources for MCP server '{server_name}': {err:#}"); - } - Err(err) => { - warn!("Task panic when listing resources for MCP server: {err:#}"); - } - } - } - - aggregated - } - - /// Returns a single map that contains all resource templates. Each key is the - /// server name and the value is a vector of resource templates. - pub async fn list_all_resource_templates(&self) -> HashMap> { - let mut join_set = JoinSet::new(); - - let clients_snapshot = &self.clients; - - for (server_name, async_managed_client) in clients_snapshot { - let server_name_cloned = server_name.clone(); - let Ok(managed_client) = async_managed_client.client().await else { - continue; - }; - let client = managed_client.client.clone(); - let timeout = managed_client.tool_timeout; - - join_set.spawn(async move { - let mut collected: Vec = Vec::new(); - let mut cursor: Option = None; - - loop { - let params = cursor.as_ref().map(|next| PaginatedRequestParams { - meta: None, - cursor: Some(next.clone()), - }); - let response = match client.list_resource_templates(params, timeout).await { - Ok(result) => result, - Err(err) => return (server_name_cloned, Err(err)), - }; - - collected.extend(response.resource_templates); - - match response.next_cursor { - Some(next) => { - if cursor.as_ref() == Some(&next) { - return ( - server_name_cloned, - Err(anyhow!( - "resources/templates/list returned duplicate cursor" - )), - ); - } - cursor = Some(next); - } - None => return (server_name_cloned, Ok(collected)), - } - } - }); - } - - let mut aggregated: HashMap> = HashMap::new(); - - while let Some(join_res) = join_set.join_next().await { - match join_res { - Ok((server_name, Ok(templates))) => { - aggregated.insert(server_name, templates); - } - Ok((server_name, Err(err))) => { - warn!( - "Failed to list resource templates for MCP server '{server_name}': {err:#}" - ); - } - Err(err) => { - warn!("Task panic when listing resource templates for MCP server: {err:#}"); - } - } - } - - aggregated - } - - /// Invoke the tool indicated by the (server, tool) pair. - pub async fn call_tool( - &self, - server: &str, - tool: &str, - arguments: Option, - meta: Option, - ) -> Result { - let client = self.client_by_name(server).await?; - if !client.tool_filter.allows(tool) { - return Err(anyhow!( - "tool '{tool}' is disabled for MCP server '{server}'" - )); - } - - let result: rmcp::model::CallToolResult = client - .client - .call_tool(tool.to_string(), arguments, meta, client.tool_timeout) - .await - .with_context(|| format!("tool call failed for `{server}/{tool}`"))?; - - let content = result - .content - .into_iter() - .map(|content| { - serde_json::to_value(content) - .unwrap_or_else(|_| serde_json::Value::String("".to_string())) - }) - .collect(); - - Ok(CallToolResult { - content, - structured_content: result.structured_content, - is_error: result.is_error, - meta: result.meta.and_then(|meta| serde_json::to_value(meta).ok()), - }) - } - - pub async fn server_supports_sandbox_state_meta_capability( - &self, - server: &str, - ) -> Result { - Ok(self - .client_by_name(server) - .await? - .server_supports_sandbox_state_meta_capability) - } - - /// List resources from the specified server. - pub async fn list_resources( - &self, - server: &str, - params: Option, - ) -> Result { - let managed = self.client_by_name(server).await?; - let timeout = managed.tool_timeout; - - managed - .client - .list_resources(params, timeout) - .await - .with_context(|| format!("resources/list failed for `{server}`")) - } - - /// List resource templates from the specified server. - pub async fn list_resource_templates( - &self, - server: &str, - params: Option, - ) -> Result { - let managed = self.client_by_name(server).await?; - let client = managed.client.clone(); - let timeout = managed.tool_timeout; - - client - .list_resource_templates(params, timeout) - .await - .with_context(|| format!("resources/templates/list failed for `{server}`")) - } - - /// Read a resource from the specified server. - pub async fn read_resource( - &self, - server: &str, - params: ReadResourceRequestParams, - ) -> Result { - let managed = self.client_by_name(server).await?; - let client = managed.client.clone(); - let timeout = managed.tool_timeout; - let uri = params.uri.clone(); - - client - .read_resource(params, timeout) - .await - .with_context(|| format!("resources/read failed for `{server}` ({uri})")) - } - - pub async fn resolve_tool_info(&self, tool_name: &ToolName) -> Option { - let all_tools = self.list_all_tools().await; - all_tools - .into_values() - .find(|tool| tool.canonical_tool_name() == *tool_name) - } -} - -async fn emit_update( - submit_id: &str, - tx_event: &Sender, - update: McpStartupUpdateEvent, -) -> Result<(), async_channel::SendError> { - tx_event - .send(Event { - id: submit_id.to_string(), - msg: EventMsg::McpStartupUpdate(update), - }) - .await -} - -/// A tool is allowed to be used if both are true: -/// 1. enabled is None (no allowlist is set) or the tool is explicitly enabled. -/// 2. The tool is not explicitly disabled. -#[derive(Default, Clone)] -pub(crate) struct ToolFilter { - enabled: Option>, - disabled: HashSet, -} - -impl ToolFilter { - fn from_config(cfg: &McpServerConfig) -> Self { - let enabled = cfg - .enabled_tools - .as_ref() - .map(|tools| tools.iter().cloned().collect::>()); - let disabled = cfg - .disabled_tools - .as_ref() - .map(|tools| tools.iter().cloned().collect::>()) - .unwrap_or_default(); - - Self { enabled, disabled } - } - - fn allows(&self, tool_name: &str) -> bool { - if let Some(enabled) = &self.enabled - && !enabled.contains(tool_name) - { - return false; - } - - !self.disabled.contains(tool_name) - } -} - -fn filter_tools(tools: Vec, filter: &ToolFilter) -> Vec { - tools - .into_iter() - .filter(|tool| filter.allows(&tool.tool.name)) - .collect() -} - -pub fn filter_non_codex_apps_mcp_tools_only( - mcp_tools: &HashMap, -) -> HashMap { - mcp_tools - .iter() - .filter(|(_, tool)| tool.server_name != CODEX_APPS_MCP_SERVER_NAME) - .map(|(name, tool)| (name.clone(), tool.clone())) - .collect() -} - -fn normalize_codex_apps_tool_title( - server_name: &str, - connector_name: Option<&str>, - value: &str, -) -> String { - if server_name != CODEX_APPS_MCP_SERVER_NAME { - return value.to_string(); - } - - let Some(connector_name) = connector_name - .map(str::trim) - .filter(|name| !name.is_empty()) - else { - return value.to_string(); - }; - - let prefix = format!("{connector_name}_"); - if let Some(stripped) = value.strip_prefix(&prefix) - && !stripped.is_empty() - { - return stripped.to_string(); - } - - value.to_string() -} - -fn normalize_codex_apps_callable_name( - server_name: &str, - tool_name: &str, - connector_id: Option<&str>, - connector_name: Option<&str>, -) -> String { - if server_name != CODEX_APPS_MCP_SERVER_NAME { - return tool_name.to_string(); - } - - let tool_name = sanitize_name(tool_name); - - if let Some(connector_name) = connector_name - .map(str::trim) - .map(sanitize_name) - .filter(|name| !name.is_empty()) - && let Some(stripped) = tool_name.strip_prefix(&connector_name) - && !stripped.is_empty() - { - return stripped.to_string(); - } - - if let Some(connector_id) = connector_id - .map(str::trim) - .map(sanitize_name) - .filter(|name| !name.is_empty()) - && let Some(stripped) = tool_name.strip_prefix(&connector_id) - && !stripped.is_empty() - { - return stripped.to_string(); - } - - tool_name -} - -fn normalize_codex_apps_callable_namespace( - server_name: &str, - connector_name: Option<&str>, -) -> String { - if server_name == CODEX_APPS_MCP_SERVER_NAME - && let Some(connector_name) = connector_name - { - format!( - "mcp{}{}{}{}", - MCP_TOOL_NAME_DELIMITER, - server_name, - MCP_TOOL_NAME_DELIMITER, - sanitize_name(connector_name) - ) - } else { - format!("mcp{MCP_TOOL_NAME_DELIMITER}{server_name}{MCP_TOOL_NAME_DELIMITER}") - } -} - -fn resolve_bearer_token( - server_name: &str, - bearer_token_env_var: Option<&str>, -) -> Result> { - let Some(env_var) = bearer_token_env_var else { - return Ok(None); - }; - - match env::var(env_var) { - Ok(value) => { - if value.is_empty() { - Err(anyhow!( - "Environment variable {env_var} for MCP server '{server_name}' is empty" - )) - } else { - Ok(Some(value)) - } - } - Err(env::VarError::NotPresent) => Err(anyhow!( - "Environment variable {env_var} for MCP server '{server_name}' is not set" - )), - Err(env::VarError::NotUnicode(_)) => Err(anyhow!( - "Environment variable {env_var} for MCP server '{server_name}' contains invalid Unicode" - )), - } -} - -#[derive(Debug, Clone, thiserror::Error)] -enum StartupOutcomeError { - #[error("MCP startup cancelled")] - Cancelled, - // We can't store the original error here because anyhow::Error doesn't implement - // `Clone`. - #[error("MCP startup failed: {error}")] - Failed { error: String }, -} - -impl From for StartupOutcomeError { - fn from(error: anyhow::Error) -> Self { - Self::Failed { - error: error.to_string(), - } - } -} - -fn elicitation_capability_for_server(_server_name: &str) -> Option { - // https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#capabilities - // indicates this should be an empty object. - Some(ElicitationCapability { - form: Some(FormElicitationCapability { - schema_validation: None, - }), - url: None, - }) -} - -async fn start_server_task( - server_name: String, - client: Arc, - params: StartServerTaskParams, -) -> Result { - let StartServerTaskParams { - startup_timeout, - tool_timeout, - tool_filter, - tx_event, - elicitation_requests, - codex_apps_tools_cache_context, - } = params; - let elicitation = elicitation_capability_for_server(&server_name); - let params = InitializeRequestParams { - meta: None, - capabilities: ClientCapabilities { - experimental: None, - extensions: None, - roots: None, - sampling: None, - elicitation, - tasks: None, - }, - client_info: Implementation { - name: "codex-mcp-client".to_owned(), - version: env!("CARGO_PKG_VERSION").to_owned(), - title: Some("Codex".into()), - description: None, - icons: None, - website_url: None, - }, - protocol_version: ProtocolVersion::V_2025_06_18, - }; - - let send_elicitation = elicitation_requests.make_sender(server_name.clone(), tx_event); - - let initialize_result = client - .initialize(params, startup_timeout, send_elicitation) - .await - .map_err(StartupOutcomeError::from)?; - - let server_supports_sandbox_state_meta_capability = initialize_result - .capabilities - .experimental - .as_ref() - .and_then(|exp| exp.get(MCP_SANDBOX_STATE_META_CAPABILITY)) - .is_some(); - let list_start = Instant::now(); - let fetch_start = Instant::now(); - let tools = list_tools_for_client_uncached( - &server_name, - &client, - startup_timeout, - initialize_result.instructions.as_deref(), - ) - .await - .map_err(StartupOutcomeError::from)?; - emit_duration( - MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC, - fetch_start.elapsed(), - &[], - ); - write_cached_codex_apps_tools_if_needed( - &server_name, - codex_apps_tools_cache_context.as_ref(), - &tools, - ); - if server_name == CODEX_APPS_MCP_SERVER_NAME { - emit_duration( - MCP_TOOLS_LIST_DURATION_METRIC, - list_start.elapsed(), - &[("cache", "miss")], - ); - } - let tools = filter_tools(tools, &tool_filter); - - let managed = ManagedClient { - client: Arc::clone(&client), - tools, - tool_timeout: Some(tool_timeout), - tool_filter, - server_instructions: initialize_result.instructions, - server_supports_sandbox_state_meta_capability, - codex_apps_tools_cache_context, - }; - - Ok(managed) -} - -struct StartServerTaskParams { - startup_timeout: Option, // TODO: cancel_token should handle this. - tool_timeout: Duration, - tool_filter: ToolFilter, - tx_event: Sender, - elicitation_requests: ElicitationRequestManager, - codex_apps_tools_cache_context: Option, -} - -async fn make_rmcp_client( - server_name: &str, - config: McpServerConfig, - store_mode: OAuthCredentialsStoreMode, - runtime_environment: McpRuntimeEnvironment, -) -> Result { - let McpServerConfig { - transport, - experimental_environment, - .. - } = config; - let remote_environment = match experimental_environment.as_deref() { - None | Some("local") => false, - Some("remote") => true, - Some(environment) => { - return Err(StartupOutcomeError::from(anyhow!( - "unsupported experimental_environment `{environment}` for MCP server `{server_name}`" - ))); - } - }; - - match transport { - McpServerTransportConfig::Stdio { - command, - args, - env, - env_vars, - cwd, - } => { - let command_os: OsString = command.into(); - let args_os: Vec = args.into_iter().map(Into::into).collect(); - let env_os = env.map(|env| { - env.into_iter() - .map(|(key, value)| (key.into(), value.into())) - .collect::>() - }); - let launcher = if remote_environment { - let exec_environment = runtime_environment.environment(); - if !exec_environment.is_remote() { - return Err(StartupOutcomeError::from(anyhow!( - "remote MCP server `{server_name}` requires a remote executor environment" - ))); - } - Arc::new(ExecutorStdioServerLauncher::new( - exec_environment.get_exec_backend(), - runtime_environment.fallback_cwd(), - )) - } else { - Arc::new(LocalStdioServerLauncher) as Arc - }; - - // `RmcpClient` always sees a launched MCP stdio server. The - // launcher hides whether that means a local child process or an - // executor process whose stdin/stdout bytes cross the process API. - RmcpClient::new_stdio_client(command_os, args_os, env_os, &env_vars, cwd, launcher) - .await - .map_err(|err| StartupOutcomeError::from(anyhow!(err))) - } - McpServerTransportConfig::StreamableHttp { - url, - http_headers, - env_http_headers, - bearer_token_env_var, - } => { - if remote_environment { - if !runtime_environment.environment().is_remote() { - return Err(StartupOutcomeError::from(anyhow!( - "remote MCP server `{server_name}` requires a remote executor environment" - ))); - } - return Err(StartupOutcomeError::from(anyhow!( - // Remote HTTP needs the future low-level executor - // `network/request` API so reqwest runs on the executor side. - // Do not fall back to local HTTP here; the config explicitly - // asked for remote placement. - "remote streamable HTTP MCP server `{server_name}` is not implemented yet" - ))); - } - - // Local streamable HTTP remains the existing reqwest path from - // the orchestrator process. - let resolved_bearer_token = - match resolve_bearer_token(server_name, bearer_token_env_var.as_deref()) { - Ok(token) => token, - Err(error) => return Err(error.into()), - }; - RmcpClient::new_streamable_http_client( - server_name, - &url, - resolved_bearer_token, - http_headers, - env_http_headers, - store_mode, - ) - .await - .map_err(StartupOutcomeError::from) - } - } -} - -fn write_cached_codex_apps_tools_if_needed( - server_name: &str, - cache_context: Option<&CodexAppsToolsCacheContext>, - tools: &[ToolInfo], -) { - if server_name != CODEX_APPS_MCP_SERVER_NAME { - return; - } - - if let Some(cache_context) = cache_context { - let cache_write_start = Instant::now(); - write_cached_codex_apps_tools(cache_context, tools); - emit_duration( - MCP_TOOLS_CACHE_WRITE_DURATION_METRIC, - cache_write_start.elapsed(), - &[], - ); - } -} - -fn load_startup_cached_codex_apps_tools_snapshot( - server_name: &str, - cache_context: Option<&CodexAppsToolsCacheContext>, -) -> Option> { - if server_name != CODEX_APPS_MCP_SERVER_NAME { - return None; - } - - let cache_context = cache_context?; - - match load_cached_codex_apps_tools(cache_context) { - CachedCodexAppsToolsLoad::Hit(tools) => Some(tools), - CachedCodexAppsToolsLoad::Missing | CachedCodexAppsToolsLoad::Invalid => None, - } -} - -#[cfg(test)] -fn read_cached_codex_apps_tools( - cache_context: &CodexAppsToolsCacheContext, -) -> Option> { - match load_cached_codex_apps_tools(cache_context) { - CachedCodexAppsToolsLoad::Hit(tools) => Some(tools), - CachedCodexAppsToolsLoad::Missing | CachedCodexAppsToolsLoad::Invalid => None, - } -} - -fn load_cached_codex_apps_tools( - cache_context: &CodexAppsToolsCacheContext, -) -> CachedCodexAppsToolsLoad { - let cache_path = cache_context.cache_path(); - let bytes = match std::fs::read(cache_path) { - Ok(bytes) => bytes, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - return CachedCodexAppsToolsLoad::Missing; - } - Err(_) => return CachedCodexAppsToolsLoad::Invalid, - }; - let cache: CodexAppsToolsDiskCache = match serde_json::from_slice(&bytes) { - Ok(cache) => cache, - Err(_) => return CachedCodexAppsToolsLoad::Invalid, - }; - if cache.schema_version != CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION { - return CachedCodexAppsToolsLoad::Invalid; - } - CachedCodexAppsToolsLoad::Hit(filter_disallowed_codex_apps_tools(cache.tools)) -} - -fn write_cached_codex_apps_tools(cache_context: &CodexAppsToolsCacheContext, tools: &[ToolInfo]) { - let cache_path = cache_context.cache_path(); - if let Some(parent) = cache_path.parent() - && std::fs::create_dir_all(parent).is_err() - { - return; - } - let tools = filter_disallowed_codex_apps_tools(tools.to_vec()); - let Ok(bytes) = serde_json::to_vec_pretty(&CodexAppsToolsDiskCache { - schema_version: CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION, - tools, - }) else { - return; - }; - let _ = std::fs::write(cache_path, bytes); -} - -fn filter_disallowed_codex_apps_tools(tools: Vec) -> Vec { - tools - .into_iter() - .filter(|tool| { - tool.connector_id - .as_deref() - .is_none_or(is_connector_id_allowed) - }) - .collect() -} - -fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &str)]) { - if let Some(metrics) = codex_otel::global() { - let _ = metrics.record_duration(metric, duration, tags); - } -} - -fn transport_origin(transport: &McpServerTransportConfig) -> Option { - match transport { - McpServerTransportConfig::StreamableHttp { url, .. } => { - let parsed = Url::parse(url).ok()?; - Some(parsed.origin().ascii_serialization()) - } - McpServerTransportConfig::Stdio { .. } => Some("stdio".to_string()), - } -} - -async fn list_tools_for_client_uncached( - server_name: &str, - client: &Arc, - timeout: Option, - server_instructions: Option<&str>, -) -> Result> { - let resp = client - .list_tools_with_connector_ids(/*params*/ None, timeout) - .await?; - let tools = resp - .tools - .into_iter() - .map(|tool| { - let callable_name = normalize_codex_apps_callable_name( - server_name, - &tool.tool.name, - tool.connector_id.as_deref(), - tool.connector_name.as_deref(), - ); - let callable_namespace = normalize_codex_apps_callable_namespace( - server_name, - tool.connector_name.as_deref(), - ); - let connector_name = tool.connector_name; - let connector_description = tool.connector_description; - let mut tool_def = tool.tool; - if let Some(title) = tool_def.title.as_deref() { - let normalized_title = - normalize_codex_apps_tool_title(server_name, connector_name.as_deref(), title); - if tool_def.title.as_deref() != Some(normalized_title.as_str()) { - tool_def.title = Some(normalized_title); - } - } - ToolInfo { - server_name: server_name.to_owned(), - callable_name, - callable_namespace, - server_instructions: server_instructions.map(str::to_string), - tool: tool_def, - connector_id: tool.connector_id, - connector_name, - plugin_display_names: Vec::new(), - connector_description, - } - }) - .collect(); - if server_name == CODEX_APPS_MCP_SERVER_NAME { - return Ok(filter_disallowed_codex_apps_tools(tools)); - } - Ok(tools) -} - -fn validate_mcp_server_name(server_name: &str) -> Result<()> { - let re = regex_lite::Regex::new(r"^[a-zA-Z0-9_-]+$")?; - if !re.is_match(server_name) { - return Err(anyhow!( - "Invalid MCP server name '{server_name}': must match pattern {pattern}", - pattern = re.as_str() - )); - } - Ok(()) -} - -fn mcp_init_error_display( - server_name: &str, - entry: Option<&McpAuthStatusEntry>, - err: &StartupOutcomeError, -) -> String { - if let Some(McpServerTransportConfig::StreamableHttp { - url, - bearer_token_env_var, - http_headers, - .. - }) = &entry.map(|entry| &entry.config.transport) - && url == "https://api.githubcopilot.com/mcp/" - && bearer_token_env_var.is_none() - && http_headers.as_ref().map(HashMap::is_empty).unwrap_or(true) - { - format!( - "GitHub MCP does not support OAuth. Log in by adding a personal access token (https://github.com/settings/personal-access-tokens) to your environment and config.toml:\n[mcp_servers.{server_name}]\nbearer_token_env_var = CODEX_GITHUB_PERSONAL_ACCESS_TOKEN" - ) - } else if is_mcp_client_auth_required_error(err) { - format!( - "The {server_name} MCP server is not logged in. Run `codex mcp login {server_name}`." - ) - } else if is_mcp_client_startup_timeout_error(err) { - let startup_timeout_secs = match entry { - Some(entry) => match entry.config.startup_timeout_sec { - Some(timeout) => timeout, - None => DEFAULT_STARTUP_TIMEOUT, - }, - None => DEFAULT_STARTUP_TIMEOUT, - } - .as_secs(); - format!( - "MCP client for `{server_name}` timed out after {startup_timeout_secs} seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.{server_name}]\nstartup_timeout_sec = XX" - ) - } else { - format!("MCP client for `{server_name}` failed to start: {err:#}") - } -} - -fn is_mcp_client_auth_required_error(error: &StartupOutcomeError) -> bool { - match error { - StartupOutcomeError::Failed { error } => error.contains("Auth required"), - _ => false, - } -} - -fn is_mcp_client_startup_timeout_error(error: &StartupOutcomeError) -> bool { - match error { - StartupOutcomeError::Failed { error } => { - error.contains("request timed out") - || error.contains("timed out handshaking with MCP server") - } - _ => false, - } -} - -fn startup_outcome_error_message(error: StartupOutcomeError) -> String { - match error { - StartupOutcomeError::Cancelled => "MCP startup cancelled".to_string(), - StartupOutcomeError::Failed { error } => error, - } -} - -#[cfg(test)] -mod mcp_init_error_display_tests {} - -#[cfg(test)] -#[path = "mcp_connection_manager_tests.rs"] -mod tests; diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs new file mode 100644 index 0000000000..074e57c88c --- /dev/null +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -0,0 +1,591 @@ +//! RMCP client lifecycle for MCP server connections. +//! +//! This module owns startup of individual RMCP clients: building the transport, +//! initializing the server, listing raw tools, applying per-server tool filters, +//! and exposing cached startup snapshots while a client is still connecting. +//! Higher-level aggregation and resource/tool APIs live in +//! [`crate::connection_manager`]. + +use std::borrow::Cow; +use std::collections::HashMap; +use std::env; +use std::ffi::OsString; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::time::Duration; +use std::time::Instant; + +use crate::codex_apps::CachedCodexAppsToolsLoad; +use crate::codex_apps::CodexAppsToolsCacheContext; +use crate::codex_apps::filter_disallowed_codex_apps_tools; +use crate::codex_apps::load_cached_codex_apps_tools; +use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot; +use crate::codex_apps::normalize_codex_apps_callable_name; +use crate::codex_apps::normalize_codex_apps_callable_namespace; +use crate::codex_apps::normalize_codex_apps_tool_title; +use crate::codex_apps::write_cached_codex_apps_tools_if_needed; +use crate::elicitation::ElicitationRequestManager; +use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use crate::mcp::ToolPluginProvenance; +use crate::runtime::McpRuntimeEnvironment; +use crate::runtime::emit_duration; +use crate::tools::ToolFilter; +use crate::tools::ToolInfo; +use crate::tools::filter_tools; +use crate::tools::tool_with_model_visible_input_schema; +use anyhow::Result; +use anyhow::anyhow; +use async_channel::Sender; +use codex_api::SharedAuthProvider; +use codex_async_utils::CancelErr; +use codex_async_utils::OrCancelExt; +use codex_config::McpServerConfig; +use codex_config::McpServerTransportConfig; +use codex_config::types::OAuthCredentialsStoreMode; +use codex_exec_server::HttpClient; +use codex_exec_server::ReqwestHttpClient; +use codex_protocol::protocol::Event; +use codex_rmcp_client::ExecutorStdioServerLauncher; +use codex_rmcp_client::LocalStdioServerLauncher; +use codex_rmcp_client::RmcpClient; +use codex_rmcp_client::StdioServerLauncher; +use futures::future::BoxFuture; +use futures::future::FutureExt; +use futures::future::Shared; +use rmcp::model::ClientCapabilities; +use rmcp::model::ElicitationCapability; +use rmcp::model::FormElicitationCapability; +use rmcp::model::Implementation; +use rmcp::model::InitializeRequestParams; +use rmcp::model::ProtocolVersion; +use tokio_util::sync::CancellationToken; + +/// MCP server capability indicating that Codex should include [`SandboxState`] +/// in tool-call request `_meta` under this key. +pub const MCP_SANDBOX_STATE_META_CAPABILITY: &str = "codex/sandbox-state-meta"; + +pub(crate) const MCP_TOOLS_LIST_DURATION_METRIC: &str = "codex.mcp.tools.list.duration_ms"; +pub(crate) const MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC: &str = + "codex.mcp.tools.fetch_uncached.duration_ms"; +pub(crate) const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(30); +pub(crate) const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(120); + +#[derive(Clone)] +pub(crate) struct ManagedClient { + pub(crate) client: Arc, + pub(crate) tools: Vec, + pub(crate) tool_filter: ToolFilter, + pub(crate) tool_timeout: Option, + pub(crate) server_instructions: Option, + pub(crate) server_supports_sandbox_state_meta_capability: bool, + pub(crate) codex_apps_tools_cache_context: Option, +} + +impl ManagedClient { + fn listed_tools(&self) -> Vec { + let total_start = Instant::now(); + if let Some(cache_context) = self.codex_apps_tools_cache_context.as_ref() + && let CachedCodexAppsToolsLoad::Hit(tools) = + load_cached_codex_apps_tools(cache_context) + { + emit_duration( + MCP_TOOLS_LIST_DURATION_METRIC, + total_start.elapsed(), + &[("cache", "hit")], + ); + return filter_tools(tools, &self.tool_filter); + } + + if self.codex_apps_tools_cache_context.is_some() { + emit_duration( + MCP_TOOLS_LIST_DURATION_METRIC, + total_start.elapsed(), + &[("cache", "miss")], + ); + } + + self.tools.clone() + } +} + +#[derive(Clone)] +pub(crate) struct AsyncManagedClient { + pub(crate) client: Shared>>, + pub(crate) startup_snapshot: Option>, + pub(crate) startup_complete: Arc, + pub(crate) tool_plugin_provenance: Arc, +} + +impl AsyncManagedClient { + // Keep this constructor flat so the startup inputs remain readable at the + // single call site instead of introducing a one-off params wrapper. + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + server_name: String, + config: McpServerConfig, + store_mode: OAuthCredentialsStoreMode, + cancel_token: CancellationToken, + tx_event: Sender, + elicitation_requests: ElicitationRequestManager, + codex_apps_tools_cache_context: Option, + tool_plugin_provenance: Arc, + runtime_environment: McpRuntimeEnvironment, + runtime_auth_provider: Option, + ) -> Self { + let tool_filter = ToolFilter::from_config(&config); + let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot( + &server_name, + codex_apps_tools_cache_context.as_ref(), + ) + .map(|tools| filter_tools(tools, &tool_filter)); + let startup_tool_filter = tool_filter; + let startup_complete = Arc::new(AtomicBool::new(false)); + let startup_complete_for_fut = Arc::clone(&startup_complete); + let fut = async move { + let outcome = async { + if let Err(error) = validate_mcp_server_name(&server_name) { + return Err(error.into()); + } + + let client = Arc::new( + make_rmcp_client( + &server_name, + config.clone(), + store_mode, + runtime_environment, + runtime_auth_provider, + ) + .await?, + ); + match start_server_task( + server_name, + client, + StartServerTaskParams { + startup_timeout: config + .startup_timeout_sec + .or(Some(DEFAULT_STARTUP_TIMEOUT)), + tool_timeout: config.tool_timeout_sec.unwrap_or(DEFAULT_TOOL_TIMEOUT), + tool_filter: startup_tool_filter, + tx_event, + elicitation_requests, + codex_apps_tools_cache_context, + }, + ) + .or_cancel(&cancel_token) + .await + { + Ok(result) => result, + Err(CancelErr::Cancelled) => Err(StartupOutcomeError::Cancelled), + } + } + .await; + + startup_complete_for_fut.store(true, Ordering::Release); + outcome + }; + let client = fut.boxed().shared(); + if startup_snapshot.is_some() { + let startup_task = client.clone(); + tokio::spawn(async move { + let _ = startup_task.await; + }); + } + + Self { + client, + startup_snapshot, + startup_complete, + tool_plugin_provenance, + } + } + + pub(crate) async fn client(&self) -> Result { + self.client.clone().await + } + + fn startup_snapshot_while_initializing(&self) -> Option> { + if !self.startup_complete.load(Ordering::Acquire) { + return self.startup_snapshot.clone(); + } + None + } + + pub(crate) async fn listed_tools(&self) -> Option> { + let annotate_tools = |tools: Vec| { + let mut tools = tools; + for tool in &mut tools { + if tool.server_name == CODEX_APPS_MCP_SERVER_NAME { + tool.tool = tool_with_model_visible_input_schema(&tool.tool); + } + + let plugin_names = match tool.connector_id.as_deref() { + Some(connector_id) => self + .tool_plugin_provenance + .plugin_display_names_for_connector_id(connector_id), + None => self + .tool_plugin_provenance + .plugin_display_names_for_mcp_server_name(tool.server_name.as_str()), + }; + tool.plugin_display_names = plugin_names.to_vec(); + + if plugin_names.is_empty() { + continue; + } + + let plugin_source_note = if plugin_names.len() == 1 { + format!("This tool is part of plugin `{}`.", plugin_names[0]) + } else { + format!( + "This tool is part of plugins {}.", + plugin_names + .iter() + .map(|plugin_name| format!("`{plugin_name}`")) + .collect::>() + .join(", ") + ) + }; + let description = tool + .tool + .description + .as_deref() + .map(str::trim) + .unwrap_or(""); + let annotated_description = if description.is_empty() { + plugin_source_note + } else if matches!(description.chars().last(), Some('.' | '!' | '?')) { + format!("{description} {plugin_source_note}") + } else { + format!("{description}. {plugin_source_note}") + }; + tool.tool.description = Some(Cow::Owned(annotated_description)); + } + tools + }; + + // Keep cache payloads raw; plugin provenance is resolved per-session at read time. + let tools = if let Some(startup_tools) = self.startup_snapshot_while_initializing() { + Some(startup_tools) + } else { + match self.client().await { + Ok(client) => Some(client.listed_tools()), + Err(_) => self.startup_snapshot.clone(), + } + }; + tools.map(annotate_tools) + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub(crate) enum StartupOutcomeError { + #[error("MCP startup cancelled")] + Cancelled, + // We can't store the original error here because anyhow::Error doesn't implement + // `Clone`. + #[error("MCP startup failed: {error}")] + Failed { error: String }, +} + +impl From for StartupOutcomeError { + fn from(error: anyhow::Error) -> Self { + Self::Failed { + error: error.to_string(), + } + } +} + +pub(crate) fn elicitation_capability_for_server( + _server_name: &str, +) -> Option { + // https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#capabilities + // indicates this should be an empty object. + Some(ElicitationCapability { + form: Some(FormElicitationCapability { + schema_validation: None, + }), + url: None, + }) +} + +pub(crate) async fn list_tools_for_client_uncached( + server_name: &str, + client: &Arc, + timeout: Option, + server_instructions: Option<&str>, +) -> Result> { + let resp = client + .list_tools_with_connector_ids(/*params*/ None, timeout) + .await?; + let tools = resp + .tools + .into_iter() + .map(|tool| { + let callable_name = normalize_codex_apps_callable_name( + server_name, + &tool.tool.name, + tool.connector_id.as_deref(), + tool.connector_name.as_deref(), + ); + let callable_namespace = normalize_codex_apps_callable_namespace( + server_name, + tool.connector_name.as_deref(), + ); + let connector_name = tool.connector_name; + let connector_description = tool.connector_description; + let mut tool_def = tool.tool; + if let Some(title) = tool_def.title.as_deref() { + let normalized_title = + normalize_codex_apps_tool_title(server_name, connector_name.as_deref(), title); + if tool_def.title.as_deref() != Some(normalized_title.as_str()) { + tool_def.title = Some(normalized_title); + } + } + ToolInfo { + server_name: server_name.to_owned(), + callable_name, + callable_namespace, + server_instructions: server_instructions.map(str::to_string), + tool: tool_def, + connector_id: tool.connector_id, + connector_name, + plugin_display_names: Vec::new(), + connector_description, + } + }) + .collect(); + if server_name == CODEX_APPS_MCP_SERVER_NAME { + return Ok(filter_disallowed_codex_apps_tools(tools)); + } + Ok(tools) +} + +fn resolve_bearer_token( + server_name: &str, + bearer_token_env_var: Option<&str>, +) -> Result> { + let Some(env_var) = bearer_token_env_var else { + return Ok(None); + }; + + match env::var(env_var) { + Ok(value) => { + if value.is_empty() { + Err(anyhow!( + "Environment variable {env_var} for MCP server '{server_name}' is empty" + )) + } else { + Ok(Some(value)) + } + } + Err(env::VarError::NotPresent) => Err(anyhow!( + "Environment variable {env_var} for MCP server '{server_name}' is not set" + )), + Err(env::VarError::NotUnicode(_)) => Err(anyhow!( + "Environment variable {env_var} for MCP server '{server_name}' contains invalid Unicode" + )), + } +} + +fn validate_mcp_server_name(server_name: &str) -> Result<()> { + let re = regex_lite::Regex::new(r"^[a-zA-Z0-9_-]+$")?; + if !re.is_match(server_name) { + return Err(anyhow!( + "Invalid MCP server name '{server_name}': must match pattern {pattern}", + pattern = re.as_str() + )); + } + Ok(()) +} + +async fn start_server_task( + server_name: String, + client: Arc, + params: StartServerTaskParams, +) -> Result { + let StartServerTaskParams { + startup_timeout, + tool_timeout, + tool_filter, + tx_event, + elicitation_requests, + codex_apps_tools_cache_context, + } = params; + let elicitation = elicitation_capability_for_server(&server_name); + let params = InitializeRequestParams { + meta: None, + capabilities: ClientCapabilities { + experimental: None, + extensions: None, + roots: None, + sampling: None, + elicitation, + tasks: None, + }, + client_info: Implementation { + name: "codex-mcp-client".to_owned(), + version: env!("CARGO_PKG_VERSION").to_owned(), + title: Some("Codex".into()), + description: None, + icons: None, + website_url: None, + }, + protocol_version: ProtocolVersion::V_2025_06_18, + }; + + let send_elicitation = elicitation_requests.make_sender(server_name.clone(), tx_event); + + let initialize_result = client + .initialize(params, startup_timeout, send_elicitation) + .await + .map_err(StartupOutcomeError::from)?; + + let server_supports_sandbox_state_meta_capability = initialize_result + .capabilities + .experimental + .as_ref() + .and_then(|exp| exp.get(MCP_SANDBOX_STATE_META_CAPABILITY)) + .is_some(); + let list_start = Instant::now(); + let fetch_start = Instant::now(); + let tools = list_tools_for_client_uncached( + &server_name, + &client, + startup_timeout, + initialize_result.instructions.as_deref(), + ) + .await + .map_err(StartupOutcomeError::from)?; + emit_duration( + MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC, + fetch_start.elapsed(), + &[], + ); + write_cached_codex_apps_tools_if_needed( + &server_name, + codex_apps_tools_cache_context.as_ref(), + &tools, + ); + if server_name == CODEX_APPS_MCP_SERVER_NAME { + emit_duration( + MCP_TOOLS_LIST_DURATION_METRIC, + list_start.elapsed(), + &[("cache", "miss")], + ); + } + let tools = filter_tools(tools, &tool_filter); + + let managed = ManagedClient { + client: Arc::clone(&client), + tools, + tool_timeout: Some(tool_timeout), + tool_filter, + server_instructions: initialize_result.instructions, + server_supports_sandbox_state_meta_capability, + codex_apps_tools_cache_context, + }; + + Ok(managed) +} + +struct StartServerTaskParams { + startup_timeout: Option, // TODO: cancel_token should handle this. + tool_timeout: Duration, + tool_filter: ToolFilter, + tx_event: Sender, + elicitation_requests: ElicitationRequestManager, + codex_apps_tools_cache_context: Option, +} + +async fn make_rmcp_client( + server_name: &str, + config: McpServerConfig, + store_mode: OAuthCredentialsStoreMode, + runtime_environment: McpRuntimeEnvironment, + runtime_auth_provider: Option, +) -> Result { + let McpServerConfig { + transport, + experimental_environment, + .. + } = config; + let remote_environment = match experimental_environment.as_deref() { + None | Some("local") => false, + Some("remote") => { + if !runtime_environment.environment().is_remote() { + return Err(StartupOutcomeError::from(anyhow!( + "remote MCP server `{server_name}` requires a remote environment" + ))); + } + true + } + Some(environment) => { + return Err(StartupOutcomeError::from(anyhow!( + "unsupported experimental_environment `{environment}` for MCP server `{server_name}`" + ))); + } + }; + + match transport { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { + let command_os: OsString = command.into(); + let args_os: Vec = args.into_iter().map(Into::into).collect(); + let env_os = env.map(|env| { + env.into_iter() + .map(|(key, value)| (key.into(), value.into())) + .collect::>() + }); + let launcher = if remote_environment { + Arc::new(ExecutorStdioServerLauncher::new( + runtime_environment.environment().get_exec_backend(), + runtime_environment.fallback_cwd(), + )) + } else { + Arc::new(LocalStdioServerLauncher::new( + runtime_environment.fallback_cwd(), + )) as Arc + }; + + // `RmcpClient` always sees a launched MCP stdio server. The + // launcher hides whether that means a local child process or an + // executor process whose stdin/stdout bytes cross the process API. + RmcpClient::new_stdio_client(command_os, args_os, env_os, &env_vars, cwd, launcher) + .await + .map_err(|err| StartupOutcomeError::from(anyhow!(err))) + } + McpServerTransportConfig::StreamableHttp { + url, + http_headers, + env_http_headers, + bearer_token_env_var, + } => { + let http_client: Arc = if remote_environment { + runtime_environment.environment().get_http_client() + } else { + Arc::new(ReqwestHttpClient) + }; + let resolved_bearer_token = + match resolve_bearer_token(server_name, bearer_token_env_var.as_deref()) { + Ok(token) => token, + Err(error) => return Err(error.into()), + }; + RmcpClient::new_streamable_http_client( + server_name, + &url, + resolved_bearer_token, + http_headers, + env_http_headers, + store_mode, + http_client, + runtime_auth_provider, + ) + .await + .map_err(StartupOutcomeError::from) + } + } +} diff --git a/codex-rs/codex-mcp/src/runtime.rs b/codex-rs/codex-mcp/src/runtime.rs new file mode 100644 index 0000000000..4284c96ff6 --- /dev/null +++ b/codex-rs/codex-mcp/src/runtime.rs @@ -0,0 +1,66 @@ +//! Runtime support for Model Context Protocol (MCP) servers. +//! +//! This module contains data that describes the runtime environment in which MCP +//! servers execute, plus the sandbox state payload sent to capable servers and a +//! tiny shared metrics helper. Transport startup and orchestration live in +//! [`crate::rmcp_client`] and [`crate::connection_manager`]. + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use codex_exec_server::Environment; +use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::SandboxPolicy; + +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SandboxState { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_profile: Option, + pub sandbox_policy: SandboxPolicy, + pub codex_linux_sandbox_exe: Option, + pub sandbox_cwd: PathBuf, + #[serde(default)] + pub use_legacy_landlock: bool, +} + +/// Runtime placement information used when starting MCP server transports. +/// +/// `McpConfig` describes what servers exist. This value describes where those +/// servers should run for the current caller. Keep it explicit at manager +/// construction time so status/snapshot paths and real sessions make the same +/// local-vs-remote decision. `fallback_cwd` is not a per-server override; it is +/// used when a stdio server omits `cwd` and the launcher needs a concrete +/// process working directory. +#[derive(Clone)] +pub struct McpRuntimeEnvironment { + environment: Arc, + fallback_cwd: PathBuf, +} + +impl McpRuntimeEnvironment { + pub fn new(environment: Arc, fallback_cwd: PathBuf) -> Self { + Self { + environment, + fallback_cwd, + } + } + + pub(crate) fn environment(&self) -> Arc { + Arc::clone(&self.environment) + } + + pub(crate) fn fallback_cwd(&self) -> PathBuf { + self.fallback_cwd.clone() + } +} + +pub(crate) fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &str)]) { + if let Some(metrics) = codex_otel::global() { + let _ = metrics.record_duration(metric, duration, tags); + } +} diff --git a/codex-rs/codex-mcp/src/mcp_tool_names.rs b/codex-rs/codex-mcp/src/tools.rs similarity index 53% rename from codex-rs/codex-mcp/src/mcp_tool_names.rs rename to codex-rs/codex-mcp/src/tools.rs index 5a323dfe71..9b677e8a07 100644 --- a/codex-rs/codex-mcp/src/mcp_tool_names.rs +++ b/codex-rs/codex-mcp/src/tools.rs @@ -1,106 +1,133 @@ -//! Allocates model-visible MCP tool names while preserving raw MCP identities. +//! MCP tool metadata, filtering, schema shaping, and name qualification. +//! +//! Raw MCP tool identities must be preserved for protocol calls, while +//! model-visible tool names must be sanitized, deduplicated, and kept within API +//! limits. This module owns that translation as well as the shared [`ToolInfo`] +//! type and helpers that adjust tool schemas before exposing them to the model. use std::collections::HashMap; use std::collections::HashSet; +use std::sync::Arc; +use codex_config::McpServerConfig; +use codex_protocol::ToolName; +use rmcp::model::Tool; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Map; +use serde_json::Value as JsonValue; use sha1::Digest; use sha1::Sha1; use tracing::warn; use crate::mcp::sanitize_responses_api_tool_name; -use crate::mcp_connection_manager::ToolInfo; -const MCP_TOOL_NAME_DELIMITER: &str = "__"; -const MAX_TOOL_NAME_LENGTH: usize = 64; -const CALLABLE_NAME_HASH_LEN: usize = 12; +pub(crate) const MCP_TOOLS_CACHE_WRITE_DURATION_METRIC: &str = + "codex.mcp.tools.cache_write.duration_ms"; -fn sha1_hex(s: &str) -> String { - let mut hasher = Sha1::new(); - hasher.update(s.as_bytes()); - let sha1 = hasher.finalize(); - format!("{sha1:x}") +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolInfo { + /// Raw MCP server name used for routing the tool call. + pub server_name: String, + /// Model-visible tool name used in Responses API tool declarations. + #[serde(rename = "tool_name", alias = "callable_name")] + pub callable_name: String, + /// Model-visible namespace used for deferred tool loading. + #[serde(rename = "tool_namespace", alias = "callable_namespace")] + pub callable_namespace: String, + /// Instructions from the MCP server initialize result. + #[serde(default)] + pub server_instructions: Option, + /// Raw MCP tool definition; `tool.name` is sent back to the MCP server. + pub tool: Tool, + pub connector_id: Option, + pub connector_name: Option, + #[serde(default)] + pub plugin_display_names: Vec, + pub connector_description: Option, } -fn callable_name_hash_suffix(raw_identity: &str) -> String { - let hash = sha1_hex(raw_identity); - format!("_{}", &hash[..CALLABLE_NAME_HASH_LEN]) -} - -fn append_hash_suffix(value: &str, raw_identity: &str) -> String { - format!("{value}{}", callable_name_hash_suffix(raw_identity)) -} - -fn append_namespace_hash_suffix(namespace: &str, raw_identity: &str) -> String { - if let Some(namespace) = namespace.strip_suffix(MCP_TOOL_NAME_DELIMITER) { - format!( - "{}{}{}", - namespace, - callable_name_hash_suffix(raw_identity), - MCP_TOOL_NAME_DELIMITER - ) - } else { - append_hash_suffix(namespace, raw_identity) +impl ToolInfo { + pub fn canonical_tool_name(&self) -> ToolName { + ToolName::namespaced(self.callable_namespace.clone(), self.callable_name.clone()) } } -fn truncate_name(value: &str, max_len: usize) -> String { - value.chars().take(max_len).collect() +pub fn declared_openai_file_input_param_names( + meta: Option<&Map>, +) -> Vec { + let Some(meta) = meta else { + return Vec::new(); + }; + + meta.get(META_OPENAI_FILE_PARAMS) + .and_then(JsonValue::as_array) + .into_iter() + .flatten() + .filter_map(JsonValue::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect() } -fn fit_callable_parts_with_hash( - namespace: &str, - tool_name: &str, - raw_identity: &str, -) -> (String, String) { - let suffix = callable_name_hash_suffix(raw_identity); - let max_tool_len = MAX_TOOL_NAME_LENGTH.saturating_sub(namespace.len()); - if max_tool_len >= suffix.len() { - let prefix_len = max_tool_len - suffix.len(); - return ( - namespace.to_string(), - format!("{}{}", truncate_name(tool_name, prefix_len), suffix), - ); - } - - let max_namespace_len = MAX_TOOL_NAME_LENGTH - suffix.len(); - (truncate_name(namespace, max_namespace_len), suffix) +/// A tool is allowed to be used if both are true: +/// 1. enabled is None (no allowlist is set) or the tool is explicitly enabled. +/// 2. The tool is not explicitly disabled. +#[derive(Default, Clone)] +pub(crate) struct ToolFilter { + pub(crate) enabled: Option>, + pub(crate) disabled: HashSet, } -fn unique_callable_parts( - namespace: &str, - tool_name: &str, - raw_identity: &str, - used_names: &mut HashSet, -) -> (String, String, String) { - let qualified_name = format!("{namespace}{tool_name}"); - if qualified_name.len() <= MAX_TOOL_NAME_LENGTH && used_names.insert(qualified_name.clone()) { - return (namespace.to_string(), tool_name.to_string(), qualified_name); +impl ToolFilter { + pub(crate) fn from_config(cfg: &McpServerConfig) -> Self { + let enabled = cfg + .enabled_tools + .as_ref() + .map(|tools| tools.iter().cloned().collect::>()); + let disabled = cfg + .disabled_tools + .as_ref() + .map(|tools| tools.iter().cloned().collect::>()) + .unwrap_or_default(); + + Self { enabled, disabled } } - let mut attempt = 0_u32; - loop { - let hash_input = if attempt == 0 { - raw_identity.to_string() - } else { - format!("{raw_identity}\0{attempt}") - }; - let (namespace, tool_name) = - fit_callable_parts_with_hash(namespace, tool_name, &hash_input); - let qualified_name = format!("{namespace}{tool_name}"); - if used_names.insert(qualified_name.clone()) { - return (namespace, tool_name, qualified_name); + pub(crate) fn allows(&self, tool_name: &str) -> bool { + if let Some(enabled) = &self.enabled + && !enabled.contains(tool_name) + { + return false; } - attempt = attempt.saturating_add(1); + + !self.disabled.contains(tool_name) } } -#[derive(Debug)] -struct CallableToolCandidate { - tool: ToolInfo, - raw_namespace_identity: String, - raw_tool_identity: String, - callable_namespace: String, - callable_name: String, +/// Returns the model-visible view of a tool while preserving the raw metadata +/// used by execution. Keep cache entries raw and call this at manager return +/// boundaries. +pub(crate) fn tool_with_model_visible_input_schema(tool: &Tool) -> Tool { + let file_params = declared_openai_file_input_param_names(tool.meta.as_deref()); + if file_params.is_empty() { + return tool.clone(); + } + + let mut tool = tool.clone(); + let mut input_schema = JsonValue::Object(tool.input_schema.as_ref().clone()); + mask_input_schema_for_file_path_params(&mut input_schema, &file_params); + if let JsonValue::Object(input_schema) = input_schema { + tool.input_schema = Arc::new(input_schema); + } + tool +} + +pub(crate) fn filter_tools(tools: Vec, filter: &ToolFilter) -> Vec { + tools + .into_iter() + .filter(|tool| filter.allows(&tool.tool.name)) + .collect() } /// Returns a qualified-name lookup for MCP tools. @@ -200,3 +227,143 @@ where } qualified_tools } + +#[derive(Debug)] +struct CallableToolCandidate { + tool: ToolInfo, + raw_namespace_identity: String, + raw_tool_identity: String, + callable_namespace: String, + callable_name: String, +} + +const MCP_TOOL_NAME_DELIMITER: &str = "__"; +const MAX_TOOL_NAME_LENGTH: usize = 64; +const CALLABLE_NAME_HASH_LEN: usize = 12; +const META_OPENAI_FILE_PARAMS: &str = "openai/fileParams"; + +fn mask_input_schema_for_file_path_params(input_schema: &mut JsonValue, file_params: &[String]) { + let Some(properties) = input_schema + .as_object_mut() + .and_then(|schema| schema.get_mut("properties")) + .and_then(JsonValue::as_object_mut) + else { + return; + }; + + for field_name in file_params { + let Some(property_schema) = properties.get_mut(field_name) else { + continue; + }; + mask_input_property_schema(property_schema); + } +} + +fn mask_input_property_schema(schema: &mut JsonValue) { + let Some(object) = schema.as_object_mut() else { + return; + }; + + let mut description = object + .get("description") + .and_then(JsonValue::as_str) + .map(str::to_string) + .unwrap_or_default(); + let guidance = "This parameter expects an absolute local file path. If you want to upload a file, provide the absolute path to that file here."; + if description.is_empty() { + description = guidance.to_string(); + } else if !description.contains(guidance) { + description = format!("{description} {guidance}"); + } + + let is_array = object.get("type").and_then(JsonValue::as_str) == Some("array") + || object.get("items").is_some(); + object.clear(); + object.insert("description".to_string(), JsonValue::String(description)); + if is_array { + object.insert("type".to_string(), JsonValue::String("array".to_string())); + object.insert("items".to_string(), serde_json::json!({ "type": "string" })); + } else { + object.insert("type".to_string(), JsonValue::String("string".to_string())); + } +} + +fn sha1_hex(s: &str) -> String { + let mut hasher = Sha1::new(); + hasher.update(s.as_bytes()); + let sha1 = hasher.finalize(); + format!("{sha1:x}") +} + +fn callable_name_hash_suffix(raw_identity: &str) -> String { + let hash = sha1_hex(raw_identity); + format!("_{}", &hash[..CALLABLE_NAME_HASH_LEN]) +} + +fn append_hash_suffix(value: &str, raw_identity: &str) -> String { + format!("{value}{}", callable_name_hash_suffix(raw_identity)) +} + +fn append_namespace_hash_suffix(namespace: &str, raw_identity: &str) -> String { + if let Some(namespace) = namespace.strip_suffix(MCP_TOOL_NAME_DELIMITER) { + format!( + "{}{}{}", + namespace, + callable_name_hash_suffix(raw_identity), + MCP_TOOL_NAME_DELIMITER + ) + } else { + append_hash_suffix(namespace, raw_identity) + } +} + +fn truncate_name(value: &str, max_len: usize) -> String { + value.chars().take(max_len).collect() +} + +fn fit_callable_parts_with_hash( + namespace: &str, + tool_name: &str, + raw_identity: &str, +) -> (String, String) { + let suffix = callable_name_hash_suffix(raw_identity); + let max_tool_len = MAX_TOOL_NAME_LENGTH.saturating_sub(namespace.len()); + if max_tool_len >= suffix.len() { + let prefix_len = max_tool_len - suffix.len(); + return ( + namespace.to_string(), + format!("{}{}", truncate_name(tool_name, prefix_len), suffix), + ); + } + + let max_namespace_len = MAX_TOOL_NAME_LENGTH - suffix.len(); + (truncate_name(namespace, max_namespace_len), suffix) +} + +fn unique_callable_parts( + namespace: &str, + tool_name: &str, + raw_identity: &str, + used_names: &mut HashSet, +) -> (String, String, String) { + let qualified_name = format!("{namespace}{tool_name}"); + if qualified_name.len() <= MAX_TOOL_NAME_LENGTH && used_names.insert(qualified_name.clone()) { + return (namespace.to_string(), tool_name.to_string(), qualified_name); + } + + let mut attempt = 0_u32; + loop { + let hash_input = if attempt == 0 { + raw_identity.to_string() + } else { + format!("{raw_identity}\0{attempt}") + }; + let (namespace, tool_name) = + fit_callable_parts_with_hash(namespace, tool_name, &hash_input); + let qualified_name = format!("{namespace}{tool_name}"); + if used_names.insert(qualified_name.clone()) { + return (namespace, tool_name, qualified_name); + } + attempt = attempt.saturating_add(1); + } +} diff --git a/codex-rs/config/Cargo.toml b/codex-rs/config/Cargo.toml index a7a50b0720..8cef4070c9 100644 --- a/codex-rs/config/Cargo.toml +++ b/codex-rs/config/Cargo.toml @@ -4,23 +4,32 @@ version.workspace = true edition.workspace = true license.workspace = true +[[example]] +name = "generate-proto" +path = "examples/generate-proto.rs" + [lints] workspace = true [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } +base64 = { workspace = true } codex-app-server-protocol = { workspace = true } codex-execpolicy = { workspace = true } codex-features = { workspace = true } +codex-file-system = { workspace = true } +codex-git-utils = { workspace = true } codex-model-provider-info = { workspace = true } codex-network-proxy = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-path = { workspace = true } +dunce = { workspace = true } futures = { workspace = true, features = ["alloc", "std"] } gethostname = { workspace = true } multimap = { workspace = true } +prost = "0.14.3" schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } @@ -30,6 +39,8 @@ thiserror = { workspace = true } tokio = { workspace = true, features = ["fs"] } toml = { workspace = true } toml_edit = { workspace = true } +tonic = { workspace = true } +tonic-prost = { workspace = true } tracing = { workspace = true } wildmatch = { workspace = true } @@ -37,10 +48,21 @@ wildmatch = { workspace = true } dns-lookup = { workspace = true } libc = { workspace = true } +[target.'cfg(target_os = "macos")'.dependencies] +core-foundation = "0.9" + [target.'cfg(target_os = "windows")'.dependencies] winapi-util = { workspace = true } +windows-sys = { version = "0.52", features = [ + "Win32_Foundation", + "Win32_System_Com", + "Win32_UI_Shell", +] } [dev-dependencies] pretty_assertions = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["full"] } +tokio-stream = { workspace = true, features = ["net"] } +tonic = { workspace = true, features = ["router", "transport"] } +tonic-prost-build = { version = "=0.14.3", default-features = false, features = ["transport"] } diff --git a/codex-rs/config/examples/generate-proto.rs b/codex-rs/config/examples/generate-proto.rs new file mode 100644 index 0000000000..03f0f796da --- /dev/null +++ b/codex-rs/config/examples/generate-proto.rs @@ -0,0 +1,19 @@ +use std::path::PathBuf; + +fn main() -> Result<(), Box> { + let Some(proto_dir_arg) = std::env::args().nth(1) else { + eprintln!("Usage: generate-proto "); + std::process::exit(1); + }; + + let proto_dir = PathBuf::from(proto_dir_arg); + let proto_file = proto_dir.join("codex.thread_config.v1.proto"); + + tonic_prost_build::configure() + .build_client(true) + .build_server(true) + .out_dir(&proto_dir) + .compile_protos(&[proto_file], &[proto_dir])?; + + Ok(()) +} diff --git a/codex-rs/config/scripts/generate-proto.sh b/codex-rs/config/scripts/generate-proto.sh new file mode 100755 index 0000000000..86af22b895 --- /dev/null +++ b/codex-rs/config/scripts/generate-proto.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../../.." && pwd)" +proto_dir="$repo_root/codex-rs/config/src/thread_config/proto" +generated="$proto_dir/codex.thread_config.v1.rs" +tmpdir="$(mktemp -d)" + +cleanup() { + rm -rf "$tmpdir" +} +trap cleanup EXIT + +( + cd "$repo_root/codex-rs" + CARGO_TARGET_DIR="$tmpdir/target" cargo run \ + -p codex-config \ + --example generate-proto \ + -- "$proto_dir" +) + +if ! sed -n '2p' "$generated" | grep -q 'clippy::trivially_copy_pass_by_ref'; then + { + sed -n '1p' "$generated" + printf '#![allow(clippy::trivially_copy_pass_by_ref)]\n' + sed '1d' "$generated" + } > "$tmpdir/generated.rs" + mv "$tmpdir/generated.rs" "$generated" +fi + +rustfmt --edition 2024 "$generated" + +awk ' + NR == 3 && previous ~ /clippy::trivially_copy_pass_by_ref/ && $0 != "" { print "" } + { print; previous = $0 } +' "$generated" > "$tmpdir/formatted.rs" +mv "$tmpdir/formatted.rs" "$generated" diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 57ee0601e9..a91ae892f5 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -1,8 +1,8 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::WebSearchMode; +use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde::Serialize; @@ -17,6 +17,7 @@ use super::requirements_exec_policy::RequirementsExecPolicy; use super::requirements_exec_policy::RequirementsExecPolicyToml; use crate::Constrained; use crate::ConstraintError; +use crate::ManagedHooksRequirementsToml; #[derive(Debug, Clone, PartialEq, Eq)] pub enum RequirementSource { @@ -83,9 +84,10 @@ impl std::ops::DerefMut for ConstrainedWithSource { pub struct ConfigRequirements { pub approval_policy: ConstrainedWithSource, pub approvals_reviewer: ConstrainedWithSource, - pub sandbox_policy: ConstrainedWithSource, + pub permission_profile: ConstrainedWithSource, pub web_search_mode: ConstrainedWithSource, pub feature_requirements: Option>, + pub managed_hooks: Option>, pub mcp_servers: Option>>, pub exec_policy: Option>, pub enforce_residency: ConstrainedWithSource>, @@ -93,6 +95,8 @@ pub struct ConfigRequirements { pub network: Option>, /// Managed filesystem constraints derived from requirements. pub filesystem: Option>, + /// Source for the managed guardian policy config, when one is configured. + pub guardian_policy_config_source: Option, } impl Default for ConfigRequirements { @@ -106,8 +110,8 @@ impl Default for ConfigRequirements { Constrained::allow_any_from_default(), /*source*/ None, ), - sandbox_policy: ConstrainedWithSource::new( - Constrained::allow_any(SandboxPolicy::new_read_only_policy()), + permission_profile: ConstrainedWithSource::new( + Constrained::allow_any(PermissionProfile::read_only()), /*source*/ None, ), web_search_mode: ConstrainedWithSource::new( @@ -115,6 +119,7 @@ impl Default for ConfigRequirements { /*source*/ None, ), feature_requirements: None, + managed_hooks: None, mcp_servers: None, exec_policy: None, enforce_residency: ConstrainedWithSource::new( @@ -123,6 +128,7 @@ impl Default for ConfigRequirements { ), network: None, filesystem: None, + guardian_policy_config_source: None, } } } @@ -625,6 +631,7 @@ pub struct ConfigRequirementsToml { pub allowed_web_search_modes: Option>, #[serde(rename = "features", alias = "feature_requirements")] pub feature_requirements: Option, + pub hooks: Option, pub mcp_servers: Option>, pub apps: Option, pub rules: Option, @@ -670,6 +677,7 @@ pub struct ConfigRequirementsWithSources { pub allowed_sandbox_modes: Option>>, pub allowed_web_search_modes: Option>>, pub feature_requirements: Option>, + pub hooks: Option>, pub mcp_servers: Option>>, pub apps: Option>, pub rules: Option>, @@ -704,6 +712,7 @@ impl ConfigRequirementsWithSources { remote_sandbox_config: _, allowed_web_search_modes: _, feature_requirements: _, + hooks: _, mcp_servers: _, apps: _, rules: _, @@ -731,6 +740,7 @@ impl ConfigRequirementsWithSources { allowed_sandbox_modes, allowed_web_search_modes, feature_requirements, + hooks, mcp_servers, rules, enforce_residency, @@ -756,6 +766,7 @@ impl ConfigRequirementsWithSources { allowed_sandbox_modes, allowed_web_search_modes, feature_requirements, + hooks, mcp_servers, apps, rules, @@ -771,6 +782,7 @@ impl ConfigRequirementsWithSources { remote_sandbox_config: None, allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value), feature_requirements: feature_requirements.map(|sourced| sourced.value), + hooks: hooks.map(|sourced| sourced.value), mcp_servers: mcp_servers.map(|sourced| sourced.value), apps: apps.map(|sourced| sourced.value), rules: rules.map(|sourced| sourced.value), @@ -830,10 +842,10 @@ pub enum ResidencyRequirement { impl ConfigRequirementsToml { pub fn apply_remote_sandbox_config(&mut self, hostname: Option<&str>) { - let Some(hostname) = hostname.and_then(normalize_hostname) else { + let Some(remote_sandbox_config) = self.remote_sandbox_config.as_ref() else { return; }; - let Some(remote_sandbox_config) = self.remote_sandbox_config.as_ref() else { + let Some(hostname) = hostname.and_then(normalize_hostname) else { return; }; let Some(matched_config) = remote_sandbox_config @@ -855,6 +867,10 @@ impl ConfigRequirementsToml { .feature_requirements .as_ref() .is_none_or(FeatureRequirementsToml::is_empty) + && self + .hooks + .as_ref() + .is_none_or(ManagedHooksRequirementsToml::is_empty) && self.mcp_servers.is_none() && self .apps @@ -881,13 +897,14 @@ impl TryFrom for ConfigRequirements { allowed_sandbox_modes, allowed_web_search_modes, feature_requirements, + hooks, mcp_servers, apps: _apps, rules, enforce_residency, network, permissions, - guardian_policy_config: _guardian_policy_config, + guardian_policy_config, } = toml; let approval_policy = match allowed_approval_policies { @@ -950,15 +967,8 @@ impl TryFrom for ConfigRequirements { ), }; - // TODO(gt): `ConfigRequirementsToml` should let the author specify the - // default `SandboxPolicy`? Should do this for `AskForApproval` too? - // - // Currently, we force ReadOnly as the default policy because two of - // the other variants (WorkspaceWrite, ExternalSandbox) require - // additional parameters. Ultimately, we should expand the config - // format to allow specifying those parameters. - let default_sandbox_policy = SandboxPolicy::new_read_only_policy(); - let sandbox_policy = match allowed_sandbox_modes { + let default_permission_profile = PermissionProfile::read_only(); + let permission_profile = match allowed_sandbox_modes { Some(Sourced { value: modes, source: requirement_source, @@ -967,23 +977,15 @@ impl TryFrom for ConfigRequirements { return Err(ConstraintError::InvalidValue { field_name: "allowed_sandbox_modes", candidate: format!("{modes:?}"), - allowed: "must include 'read-only' to allow any SandboxPolicy".to_string(), + allowed: "must include 'read-only' to allow any PermissionProfile" + .to_string(), requirement_source, }); }; let requirement_source_for_error = requirement_source.clone(); - let constrained = Constrained::new(default_sandbox_policy, move |candidate| { - let mode = match candidate { - SandboxPolicy::ReadOnly { .. } => SandboxModeRequirement::ReadOnly, - SandboxPolicy::WorkspaceWrite { .. } => { - SandboxModeRequirement::WorkspaceWrite - } - SandboxPolicy::DangerFullAccess => SandboxModeRequirement::DangerFullAccess, - SandboxPolicy::ExternalSandbox { .. } => { - SandboxModeRequirement::ExternalSandbox - } - }; + let constrained = Constrained::new(default_permission_profile, move |candidate| { + let mode = sandbox_mode_requirement_for_permission_profile(candidate); if modes.contains(&mode) { Ok(()) } else { @@ -997,12 +999,10 @@ impl TryFrom for ConfigRequirements { })?; ConstrainedWithSource::new(constrained, Some(requirement_source)) } - None => { - ConstrainedWithSource::new( - Constrained::allow_any(default_sandbox_policy), - /*source*/ None, - ) - } + None => ConstrainedWithSource::new( + Constrained::allow_any(default_permission_profile), + /*source*/ None, + ), }; let exec_policy = match rules { Some(Sourced { value, source }) => { @@ -1061,6 +1061,34 @@ impl TryFrom for ConfigRequirements { }; let feature_requirements = feature_requirements.filter(|requirements| !requirements.value.is_empty()); + let managed_hooks = hooks + .filter(|managed_hooks| managed_hooks.value.handler_count() > 0) + .map(|sourced_hooks| { + let Sourced { + value, + source: requirement_source, + } = sourced_hooks; + let allowed = value; + let allowed_for_error = format!("{allowed:?}"); + let requirement_source_for_error = requirement_source.clone(); + let constrained = Constrained::new(allowed.clone(), move |candidate| { + if candidate == &allowed { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "hooks", + candidate: format!("{candidate:?}"), + allowed: allowed_for_error.clone(), + requirement_source: requirement_source_for_error.clone(), + }) + } + })?; + Ok(ConstrainedWithSource::new( + constrained, + Some(requirement_source), + )) + }) + .transpose()?; let enforce_residency = match enforce_residency { Some(Sourced { @@ -1096,29 +1124,57 @@ impl TryFrom for ConfigRequirements { let Sourced { value, source } = sourced_permissions; Sourced::new(FilesystemConstraints::from(value), source) }); + let guardian_policy_config_source = guardian_policy_config.map(|sourced| sourced.source); Ok(ConfigRequirements { approval_policy, approvals_reviewer, - sandbox_policy, + permission_profile, web_search_mode, feature_requirements, + managed_hooks, mcp_servers, exec_policy, enforce_residency, network, filesystem, + guardian_policy_config_source, }) } } +pub fn sandbox_mode_requirement_for_permission_profile( + permission_profile: &PermissionProfile, +) -> SandboxModeRequirement { + match permission_profile { + PermissionProfile::Disabled => SandboxModeRequirement::DangerFullAccess, + PermissionProfile::External { .. } => SandboxModeRequirement::ExternalSandbox, + PermissionProfile::Managed { .. } => { + let file_system_policy = permission_profile.file_system_sandbox_policy(); + if file_system_policy.has_full_disk_write_access() { + SandboxModeRequirement::DangerFullAccess + } else if file_system_policy + .entries + .iter() + .any(|entry| entry.access.can_write()) + { + SandboxModeRequirement::WorkspaceWrite + } else { + SandboxModeRequirement::ReadOnly + } + } + } +} + #[cfg(test)] mod tests { use super::*; + use crate::HookEventsToml; use anyhow::Result; use codex_execpolicy::Decision; use codex_execpolicy::Evaluation; use codex_execpolicy::RuleMatch; use codex_protocol::protocol::NetworkAccess; + use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; use pretty_assertions::assert_eq; @@ -1134,6 +1190,10 @@ mod tests { )?) } + fn profile_from_sandbox_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile { + PermissionProfile::from_legacy_sandbox_policy(sandbox_policy) + } + fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources { let ConfigRequirementsToml { allowed_approval_policies, @@ -1142,6 +1202,7 @@ mod tests { remote_sandbox_config: _, allowed_web_search_modes, feature_requirements, + hooks, mcp_servers, apps, rules, @@ -1161,6 +1222,7 @@ mod tests { .map(|value| Sourced::new(value, RequirementSource::Unknown)), feature_requirements: feature_requirements .map(|value| Sourced::new(value, RequirementSource::Unknown)), + hooks: hooks.map(|value| Sourced::new(value, RequirementSource::Unknown)), mcp_servers: mcp_servers.map(|value| Sourced::new(value, RequirementSource::Unknown)), apps: apps.map(|value| Sourced::new(value, RequirementSource::Unknown)), rules: rules.map(|value| Sourced::new(value, RequirementSource::Unknown)), @@ -1180,7 +1242,7 @@ mod tests { let allowed_approval_policies = vec![AskForApproval::UnlessTrusted, AskForApproval::Never]; let allowed_approvals_reviewers = - vec![ApprovalsReviewer::GuardianSubagent, ApprovalsReviewer::User]; + vec![ApprovalsReviewer::AutoReview, ApprovalsReviewer::User]; let allowed_sandbox_modes = vec![ SandboxModeRequirement::WorkspaceWrite, SandboxModeRequirement::DangerFullAccess, @@ -1205,6 +1267,7 @@ mod tests { remote_sandbox_config: None, allowed_web_search_modes: Some(allowed_web_search_modes.clone()), feature_requirements: Some(feature_requirements.clone()), + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -1236,6 +1299,7 @@ mod tests { feature_requirements, enforce_source.clone(), )), + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -1273,6 +1337,7 @@ mod tests { allowed_sandbox_modes: None, allowed_web_search_modes: None, feature_requirements: None, + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -1318,6 +1383,7 @@ mod tests { allowed_sandbox_modes: None, allowed_web_search_modes: None, feature_requirements: None, + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -1644,7 +1710,7 @@ allowed_approvals_reviewers = ["user"] let source: ConfigRequirementsToml = from_str( r#" allowed_approval_policies = ["on-request"] - allowed_approvals_reviewers = ["guardian_subagent"] + allowed_approvals_reviewers = ["auto_review"] allowed_sandbox_modes = ["read-only"] "#, )?; @@ -1669,8 +1735,10 @@ allowed_approvals_reviewers = ["user"] ); assert_eq!( requirements - .sandbox_policy - .can_set(&SandboxPolicy::DangerFullAccess), + .permission_profile + .can_set(&profile_from_sandbox_policy( + &SandboxPolicy::DangerFullAccess, + )), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "DangerFullAccess".into(), @@ -1685,7 +1753,7 @@ allowed_approvals_reviewers = ["user"] Err(ConstraintError::InvalidValue { field_name: "approvals_reviewer", candidate: "User".into(), - allowed: "[GuardianSubagent]".into(), + allowed: "[AutoReview]".into(), requirement_source: source_location, }) ); @@ -1725,7 +1793,7 @@ allowed_approvals_reviewers = ["user"] let source: ConfigRequirementsToml = from_str( r#" allowed_approval_policies = ["on-request"] - allowed_approvals_reviewers = ["guardian_subagent"] + allowed_approvals_reviewers = ["auto_review"] allowed_sandbox_modes = ["read-only"] allowed_web_search_modes = ["cached"] enforce_residency = "us" @@ -1748,7 +1816,7 @@ allowed_approvals_reviewers = ["user"] Some(source_location.clone()) ); assert_eq!( - requirements.sandbox_policy.source, + requirements.permission_profile.source, Some(source_location.clone()) ); assert_eq!( @@ -1814,8 +1882,10 @@ allowed_approvals_reviewers = ["user"] ); assert!( requirements - .sandbox_policy - .can_set(&SandboxPolicy::new_read_only_policy()) + .permission_profile + .can_set(&profile_from_sandbox_policy( + &SandboxPolicy::new_read_only_policy() + )) .is_ok() ); @@ -1825,20 +1895,20 @@ allowed_approvals_reviewers = ["user"] #[test] fn deserialize_allowed_approvals_reviewers() -> Result<()> { let toml_str = r#" - allowed_approvals_reviewers = ["guardian_subagent", "user"] + allowed_approvals_reviewers = ["auto_review", "user"] "#; let config: ConfigRequirementsToml = from_str(toml_str)?; let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; assert_eq!( requirements.approvals_reviewer.value(), - ApprovalsReviewer::GuardianSubagent, + ApprovalsReviewer::AutoReview, "currently, there is no way to specify the default value for approvals reviewer in the toml, so it picks the first allowed value" ); assert!( requirements .approvals_reviewer - .can_set(&ApprovalsReviewer::GuardianSubagent) + .can_set(&ApprovalsReviewer::AutoReview) .is_ok() ); assert!( @@ -1851,6 +1921,22 @@ allowed_approvals_reviewers = ["user"] Ok(()) } + #[test] + fn deserialize_legacy_allowed_approvals_reviewer() -> Result<()> { + let toml_str = r#" + allowed_approvals_reviewers = ["guardian_subagent", "user"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.approvals_reviewer.value(), + ApprovalsReviewer::AutoReview + ); + + Ok(()) + } + #[test] fn empty_allowed_approvals_reviewers_is_rejected() -> Result<()> { let toml_str = r#" @@ -1881,26 +1967,30 @@ allowed_approvals_reviewers = ["user"] let root = if cfg!(windows) { "C:\\repo" } else { "/repo" }; assert!( requirements - .sandbox_policy - .can_set(&SandboxPolicy::new_read_only_policy()) + .permission_profile + .can_set(&profile_from_sandbox_policy( + &SandboxPolicy::new_read_only_policy() + )) .is_ok() ); + let workspace_write_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?], + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; assert!( requirements - .sandbox_policy - .can_set(&SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?], - read_only_access: Default::default(), - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }) + .permission_profile + .can_set(&profile_from_sandbox_policy(&workspace_write_policy)) .is_ok() ); assert_eq!( requirements - .sandbox_policy - .can_set(&SandboxPolicy::DangerFullAccess), + .permission_profile + .can_set(&profile_from_sandbox_policy( + &SandboxPolicy::DangerFullAccess, + )), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "DangerFullAccess".into(), @@ -1910,10 +2000,12 @@ allowed_approvals_reviewers = ["user"] ); assert_eq!( requirements - .sandbox_policy - .can_set(&SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Restricted, - }), + .permission_profile + .can_set(&profile_from_sandbox_policy( + &SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + } + )), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "ExternalSandbox".into(), @@ -1994,22 +2086,24 @@ allowed_approvals_reviewers = ["user"] let requirements = ConfigRequirements::try_from(requirements_with_sources)?; let root = if cfg!(windows) { "C:\\repo" } else { "/repo" }; + let workspace_write_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?], + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; assert!( requirements - .sandbox_policy - .can_set(&SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?], - read_only_access: Default::default(), - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }) + .permission_profile + .can_set(&profile_from_sandbox_policy(&workspace_write_policy)) .is_ok() ); assert_eq!( requirements - .sandbox_policy - .can_set(&SandboxPolicy::DangerFullAccess), + .permission_profile + .can_set(&profile_from_sandbox_policy( + &SandboxPolicy::DangerFullAccess, + )), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "DangerFullAccess".into(), @@ -2039,8 +2133,10 @@ allowed_approvals_reviewers = ["user"] assert_eq!( requirements - .sandbox_policy - .can_set(&SandboxPolicy::DangerFullAccess), + .permission_profile + .can_set(&profile_from_sandbox_policy( + &SandboxPolicy::DangerFullAccess, + )), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "DangerFullAccess".into(), @@ -2078,8 +2174,10 @@ allowed_approvals_reviewers = ["user"] assert_eq!( requirements - .sandbox_policy - .can_set(&SandboxPolicy::new_workspace_write_policy()), + .permission_profile + .can_set(&profile_from_sandbox_policy( + &SandboxPolicy::new_workspace_write_policy(), + )), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "WorkspaceWrite".into(), @@ -2211,6 +2309,124 @@ allowed_approvals_reviewers = ["user"] Ok(()) } + #[test] + fn deserialize_managed_hooks_requirements() -> Result<()> { + let toml_str = r#" +managed_dir = "/enterprise/hooks" +windows_managed_dir = 'C:\enterprise\hooks' + +[[PreToolUse]] +matcher = "^Bash$" + +[[PreToolUse.hooks]] +type = "command" +command = "python3 /enterprise/hooks/pre.py" +timeout = 10 +statusMessage = "checking" + "#; + let hooks: ManagedHooksRequirementsToml = from_str(toml_str)?; + + assert_eq!( + hooks.managed_dir.as_deref(), + Some(std::path::Path::new("/enterprise/hooks")) + ); + assert_eq!(hooks.handler_count(), 1); + assert_eq!(hooks.hooks.pre_tool_use.len(), 1); + Ok(()) + } + + #[test] + fn merge_unset_fields_does_not_overwrite_existing_hooks() -> Result<()> { + let mut target = ConfigRequirementsWithSources::default(); + target.merge_unset_fields( + RequirementSource::CloudRequirements, + from_str::( + r#" +[hooks] +managed_dir = "/cloud/hooks" + +[[hooks.PreToolUse]] +matcher = "^Bash$" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "python3 /cloud/hooks/pre.py" + "#, + )?, + ); + target.merge_unset_fields( + RequirementSource::SystemRequirementsToml { + file: system_requirements_toml_file_for_test()?, + }, + from_str::( + r#" +[hooks] +managed_dir = "/system/hooks" + +[[hooks.PreToolUse]] +matcher = "^Bash$" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "python3 /system/hooks/pre.py" + "#, + )?, + ); + + assert_eq!( + target + .hooks + .as_ref() + .and_then(|hooks| hooks.value.managed_dir.as_ref()) + .map(std::path::PathBuf::as_path), + Some(std::path::Path::new("/cloud/hooks")) + ); + assert_eq!( + target.hooks.as_ref().map(|hooks| hooks.source.clone()), + Some(RequirementSource::CloudRequirements) + ); + Ok(()) + } + + #[test] + fn managed_hooks_constraint_rejects_drift() -> Result<()> { + let config: ConfigRequirementsToml = from_str( + r#" +[hooks] +managed_dir = "/enterprise/hooks" + +[[hooks.PreToolUse]] +matcher = "^Bash$" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "python3 /enterprise/hooks/pre.py" + "#, + )?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + let mut managed_hooks = requirements + .managed_hooks + .expect("expected managed hooks requirements"); + + let err = managed_hooks + .set(ManagedHooksRequirementsToml { + managed_dir: Some(std::path::PathBuf::from("/other/hooks")), + windows_managed_dir: None, + hooks: HookEventsToml::default(), + }) + .expect_err("managed hooks should reject drift"); + + assert!(matches!( + err, + ConstraintError::InvalidValue { + field_name: "hooks", + requirement_source: RequirementSource::Unknown, + .. + } + )); + Ok(()) + } + #[test] fn network_requirements_are_preserved_as_constraints_with_source() -> Result<()> { let toml_str = r#" diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 0c8e14c16d..a1692b2cb1 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -4,6 +4,7 @@ use std::collections::BTreeMap; use std::collections::HashMap; use std::path::Path; +use crate::HookEventsToml; use crate::permissions_toml::PermissionsToml; use crate::profile_toml::ConfigProfile; use crate::types::AnalyticsConfigToml; @@ -46,10 +47,10 @@ use codex_protocol::config_types::Verbosity; use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WebSearchToolConfig; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::ReadOnlyAccess; -use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path::normalize_for_path_comparison; use schemars::JsonSchema; @@ -90,6 +91,10 @@ pub struct ConfigToml { /// ARC. pub approvals_reviewer: Option, + /// Optional policy instructions for the guardian auto-reviewer. + #[serde(default)] + pub auto_review: Option, + #[serde(default)] pub shell_environment_policy: ShellEnvironmentPolicyToml, @@ -208,10 +213,12 @@ pub struct ConfigToml { /// Default: `300000` (5 minutes). pub background_terminal_max_timeout: Option, - /// Optional absolute path to the Node runtime used by `js_repl`. + /// Deprecated: ignored. + #[schemars(skip)] pub js_repl_node_path: Option, - /// Ordered list of directories to search for Node modules in `js_repl`. + /// Deprecated: ignored. + #[schemars(skip)] pub js_repl_node_module_dirs: Option>, /// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution. @@ -308,6 +315,13 @@ pub struct ConfigToml { /// Experimental / do not use. When set, app-server uses a remote thread /// store at this endpoint instead of the local filesystem/SQLite store. pub experimental_thread_store_endpoint: Option, + + /// Experimental / do not use. When set, app-server fetches thread-scoped + /// config from a remote service at this endpoint. + pub experimental_thread_config_endpoint: Option, + + /// Experimental / do not use. Selects the thread store implementation. + pub experimental_thread_store: Option, pub projects: Option>, /// Controls the web search tool mode: disabled, cached, or live. @@ -328,6 +342,9 @@ pub struct ConfigToml { /// User-level skill config entries keyed by SKILL.md path. pub skills: Option, + /// Lifecycle hooks configured inline in TOML. + pub hooks: Option, + /// User-level plugin config entries keyed by plugin name. #[serde(default)] pub plugins: HashMap, @@ -345,7 +362,8 @@ pub struct ConfigToml { /// Suppress warnings about unstable (under development) features. pub suppress_unstable_features_warning: Option, - /// Settings for ghost snapshots (used for undo). + /// Compatibility-only settings retained so legacy `ghost_snapshot` + /// config still loads. #[serde(default)] pub ghost_snapshot: Option, @@ -401,6 +419,26 @@ pub struct ConfigToml { pub oss_provider: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ThreadStoreToml { + Local {}, + Remote { + endpoint: String, + }, + #[cfg(debug_assertions)] + #[schemars(skip)] + InMemory { + id: String, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct AutoReviewToml { + /// Additional policy instructions inserted into the guardian prompt. + pub policy: Option, +} + impl From for UserSavedConfig { fn from(config_toml: ConfigToml) -> Self { let profiles = config_toml @@ -548,6 +586,9 @@ pub struct AgentsToml { /// Default maximum runtime in seconds for agent job workers. #[schemars(range(min = 1))] pub job_max_runtime_seconds: Option, + /// Whether to record a model-visible message when an agent turn is interrupted. + /// Defaults to true. + pub interrupt_message: Option, /// User-defined role declarations keyed by role name. /// @@ -589,27 +630,30 @@ impl From for Tools { #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct GhostSnapshotToml { - /// Exclude untracked files larger than this many bytes from ghost snapshots. + /// Legacy no-op setting retained for compatibility. #[serde(alias = "ignore_untracked_files_over_bytes")] pub ignore_large_untracked_files: Option, - /// Ignore untracked directories that contain this many files or more. - /// (Still emits a warning unless warnings are disabled.) + /// Legacy no-op setting retained for compatibility. #[serde(alias = "large_untracked_dir_warning_threshold")] pub ignore_large_untracked_dirs: Option, - /// Disable all ghost snapshot warning events. + /// Legacy no-op setting retained for compatibility. pub disable_warnings: Option, } impl ConfigToml { - /// Derive the effective sandbox policy from the configuration. - pub async fn derive_sandbox_policy( + /// Derive the effective permission profile from legacy sandbox config. + /// + /// Call this only after ruling out `default_permissions`: named + /// `[permissions]` profiles must be compiled through the permissions + /// profile pipeline, not reconstructed from `sandbox_mode`. + pub async fn derive_permission_profile( &self, sandbox_mode_override: Option, profile_sandbox_mode: Option, windows_sandbox_level: WindowsSandboxLevel, active_project: Option<&ProjectConfig>, - sandbox_policy_constraint: Option<&crate::Constrained>, - ) -> SandboxPolicy { + permission_profile_constraint: Option<&crate::Constrained>, + ) -> PermissionProfile { let sandbox_mode_was_explicit = sandbox_mode_override.is_some() || profile_sandbox_mode.is_some() || self.sandbox_mode.is_some(); @@ -637,49 +681,53 @@ impl ConfigToml { }) }) .unwrap_or_default(); - let mut sandbox_policy = match resolved_sandbox_mode { - SandboxMode::ReadOnly => SandboxPolicy::new_read_only_policy(), + let effective_sandbox_mode = if cfg!(target_os = "windows") + // If the experimental Windows sandbox is enabled, do not force a downgrade. + && windows_sandbox_level == WindowsSandboxLevel::Disabled + && matches!(resolved_sandbox_mode, SandboxMode::WorkspaceWrite) + { + SandboxMode::ReadOnly + } else { + resolved_sandbox_mode + }; + + let permission_profile = match effective_sandbox_mode { + SandboxMode::ReadOnly => PermissionProfile::read_only(), SandboxMode::WorkspaceWrite => match self.sandbox_workspace_write.as_ref() { Some(SandboxWorkspaceWrite { writable_roots, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, - }) => SandboxPolicy::WorkspaceWrite { - writable_roots: writable_roots.clone(), - read_only_access: ReadOnlyAccess::FullAccess, - network_access: *network_access, - exclude_tmpdir_env_var: *exclude_tmpdir_env_var, - exclude_slash_tmp: *exclude_slash_tmp, - }, - None => SandboxPolicy::new_workspace_write_policy(), + }) => { + let network_policy = if *network_access { + NetworkSandboxPolicy::Enabled + } else { + NetworkSandboxPolicy::Restricted + }; + PermissionProfile::workspace_write_with( + writable_roots, + network_policy, + *exclude_tmpdir_env_var, + *exclude_slash_tmp, + ) + } + None => PermissionProfile::workspace_write(), }, - SandboxMode::DangerFullAccess => SandboxPolicy::DangerFullAccess, + SandboxMode::DangerFullAccess => PermissionProfile::Disabled, }; - let downgrade_workspace_write_if_unsupported = |policy: &mut SandboxPolicy| { - if cfg!(target_os = "windows") - // If the experimental Windows sandbox is enabled, do not force a downgrade. - && windows_sandbox_level == WindowsSandboxLevel::Disabled - && matches!(&*policy, SandboxPolicy::WorkspaceWrite { .. }) - { - *policy = SandboxPolicy::new_read_only_policy(); - } - }; - if matches!(resolved_sandbox_mode, SandboxMode::WorkspaceWrite) { - downgrade_workspace_write_if_unsupported(&mut sandbox_policy); - } if !sandbox_mode_was_explicit - && let Some(constraint) = sandbox_policy_constraint - && let Err(err) = constraint.can_set(&sandbox_policy) + && let Some(constraint) = permission_profile_constraint + && let Err(err) = constraint.can_set(&permission_profile) { tracing::warn!( error = %err, "default sandbox policy is disallowed by requirements; falling back to required default" ); - sandbox_policy = constraint.get().clone(); - downgrade_workspace_write_if_unsupported(&mut sandbox_policy); + PermissionProfile::read_only() + } else { + permission_profile } - sandbox_policy } /// Resolves the cwd to an existing project, or returns None if ConfigToml diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs new file mode 100644 index 0000000000..8a5c73d6b9 --- /dev/null +++ b/codex-rs/config/src/hook_config.rs @@ -0,0 +1,148 @@ +use std::path::Path; +use std::path::PathBuf; + +use codex_protocol::protocol::HookEventName; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct HooksFile { + #[serde(default)] + pub hooks: HookEventsToml, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct HookEventsToml { + #[serde(rename = "PreToolUse", default)] + pub pre_tool_use: Vec, + #[serde(rename = "PermissionRequest", default)] + pub permission_request: Vec, + #[serde(rename = "PostToolUse", default)] + pub post_tool_use: Vec, + #[serde(rename = "SessionStart", default)] + pub session_start: Vec, + #[serde(rename = "UserPromptSubmit", default)] + pub user_prompt_submit: Vec, + #[serde(rename = "Stop", default)] + pub stop: Vec, +} + +impl HookEventsToml { + pub fn is_empty(&self) -> bool { + let Self { + pre_tool_use, + permission_request, + post_tool_use, + session_start, + user_prompt_submit, + stop, + } = self; + pre_tool_use.is_empty() + && permission_request.is_empty() + && post_tool_use.is_empty() + && session_start.is_empty() + && user_prompt_submit.is_empty() + && stop.is_empty() + } + + pub fn handler_count(&self) -> usize { + let Self { + pre_tool_use, + permission_request, + post_tool_use, + session_start, + user_prompt_submit, + stop, + } = self; + [ + pre_tool_use, + permission_request, + post_tool_use, + session_start, + user_prompt_submit, + stop, + ] + .into_iter() + .flatten() + .map(|group| group.hooks.len()) + .sum() + } + + pub fn into_matcher_groups(self) -> [(HookEventName, Vec); 6] { + [ + (HookEventName::PreToolUse, self.pre_tool_use), + (HookEventName::PermissionRequest, self.permission_request), + (HookEventName::PostToolUse, self.post_tool_use), + (HookEventName::SessionStart, self.session_start), + (HookEventName::UserPromptSubmit, self.user_prompt_submit), + (HookEventName::Stop, self.stop), + ] + } +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct MatcherGroup { + #[serde(default)] + pub matcher: Option, + #[serde(default)] + pub hooks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type")] +pub enum HookHandlerConfig { + #[serde(rename = "command")] + Command { + command: String, + #[serde(default, rename = "timeout")] + timeout_sec: Option, + #[serde(default)] + r#async: bool, + #[serde(default, rename = "statusMessage")] + status_message: Option, + }, + #[serde(rename = "prompt")] + Prompt {}, + #[serde(rename = "agent")] + Agent {}, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManagedHooksRequirementsToml { + pub managed_dir: Option, + pub windows_managed_dir: Option, + #[serde(flatten)] + pub hooks: HookEventsToml, +} + +impl ManagedHooksRequirementsToml { + pub fn is_empty(&self) -> bool { + let Self { + managed_dir, + windows_managed_dir, + hooks, + } = self; + managed_dir.is_none() && windows_managed_dir.is_none() && hooks.is_empty() + } + + pub fn handler_count(&self) -> usize { + self.hooks.handler_count() + } + + pub fn managed_dir_for_current_platform(&self) -> Option<&Path> { + #[cfg(windows)] + { + self.windows_managed_dir.as_deref() + } + + #[cfg(not(windows))] + { + self.managed_dir.as_deref() + } + } +} + +#[cfg(test)] +#[path = "hooks_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/hooks_tests.rs b/codex-rs/config/src/hooks_tests.rs new file mode 100644 index 0000000000..5e3f1df674 --- /dev/null +++ b/codex-rs/config/src/hooks_tests.rs @@ -0,0 +1,119 @@ +use pretty_assertions::assert_eq; + +use super::HookEventsToml; +use super::HookHandlerConfig; +use super::HooksFile; +use super::ManagedHooksRequirementsToml; +use super::MatcherGroup; + +#[test] +fn hooks_file_deserializes_existing_json_shape() { + let parsed: HooksFile = serde_json::from_str( + r#"{ + "hooks": { + "PreToolUse": [ + { + "matcher": "^Bash$", + "hooks": [ + { + "type": "command", + "command": "python3 /tmp/pre.py", + "timeout": 10, + "statusMessage": "checking" + } + ] + } + ] + } +}"#, + ) + .expect("hooks.json should deserialize"); + + assert_eq!( + parsed, + HooksFile { + hooks: HookEventsToml { + pre_tool_use: vec![MatcherGroup { + matcher: Some("^Bash$".to_string()), + hooks: vec![HookHandlerConfig::Command { + command: "python3 /tmp/pre.py".to_string(), + timeout_sec: Some(10), + r#async: false, + status_message: Some("checking".to_string()), + }], + }], + ..Default::default() + }, + } + ); +} + +#[test] +fn hook_events_deserialize_from_toml_arrays_of_tables() { + let parsed: HookEventsToml = toml::from_str( + r#" +[[PreToolUse]] +matcher = "^Bash$" + +[[PreToolUse.hooks]] +type = "command" +command = "python3 /tmp/pre.py" +timeout = 10 +statusMessage = "checking" +"#, + ) + .expect("hook events TOML should deserialize"); + + assert_eq!( + parsed, + HookEventsToml { + pre_tool_use: vec![MatcherGroup { + matcher: Some("^Bash$".to_string()), + hooks: vec![HookHandlerConfig::Command { + command: "python3 /tmp/pre.py".to_string(), + timeout_sec: Some(10), + r#async: false, + status_message: Some("checking".to_string()), + }], + }], + ..Default::default() + } + ); +} + +#[test] +fn managed_hooks_requirements_flatten_hook_events() { + let parsed: ManagedHooksRequirementsToml = toml::from_str( + r#" +managed_dir = "/enterprise/place" + +[[PreToolUse]] +matcher = "^Bash$" + +[[PreToolUse.hooks]] +type = "command" +command = "python3 /enterprise/place/pre.py" +"#, + ) + .expect("requirements hooks TOML should deserialize"); + + assert_eq!( + parsed, + ManagedHooksRequirementsToml { + managed_dir: Some(std::path::PathBuf::from("/enterprise/place")), + windows_managed_dir: None, + hooks: HookEventsToml { + pre_tool_use: vec![MatcherGroup { + matcher: Some("^Bash$".to_string()), + hooks: vec![HookHandlerConfig::Command { + command: "python3 /enterprise/place/pre.py".to_string(), + timeout_sec: None, + r#async: false, + status_message: None, + }], + }], + ..Default::default() + }, + } + ); +} diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index e70550f009..d628fbf04f 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -4,8 +4,10 @@ pub mod config_toml; mod constraint; mod diagnostics; mod fingerprint; +mod hook_config; mod host_name; mod key_aliases; +pub mod loader; mod marketplace_edit; mod mcp_edit; mod mcp_types; @@ -16,7 +18,6 @@ pub mod profile_toml; mod project_root_markers; mod requirements_exec_policy; pub mod schema; -pub mod shell_environment; mod skills_config; mod state; mod thread_config; @@ -27,6 +28,8 @@ pub const CONFIG_TOML_FILE: &str = "config.toml"; pub use cloud_requirements::CloudRequirementsLoadError; pub use cloud_requirements::CloudRequirementsLoadErrorCode; pub use cloud_requirements::CloudRequirementsLoader; +pub use codex_app_server_protocol::ConfigLayerSource; +pub use codex_utils_absolute_path::AbsolutePathBuf; pub use config_requirements::AppRequirementToml; pub use config_requirements::AppsRequirementsToml; pub use config_requirements::ConfigRequirements; @@ -50,6 +53,7 @@ pub use config_requirements::ResidencyRequirement; pub use config_requirements::SandboxModeRequirement; pub use config_requirements::Sourced; pub use config_requirements::WebSearchModeRequirement; +pub use config_requirements::sandbox_mode_requirement_for_permission_profile; pub use constraint::Constrained; pub use constraint::ConstraintError; pub use constraint::ConstraintResult; @@ -65,6 +69,11 @@ pub use diagnostics::format_config_error; pub use diagnostics::format_config_error_with_source; pub use diagnostics::io_error_from_config_error; pub use fingerprint::version_for_toml; +pub use hook_config::HookEventsToml; +pub use hook_config::HookHandlerConfig; +pub use hook_config::HooksFile; +pub use hook_config::ManagedHooksRequirementsToml; +pub use hook_config::MatcherGroup; pub use host_name::host_name; pub use marketplace_edit::MarketplaceConfigUpdate; pub use marketplace_edit::RemoveMarketplaceConfigOutcome; @@ -98,6 +107,7 @@ pub use state::ConfigLayerStack; pub use state::ConfigLayerStackOrdering; pub use state::LoaderOverrides; pub use thread_config::NoopThreadConfigLoader; +pub use thread_config::RemoteThreadConfigLoader; pub use thread_config::SessionThreadConfig; pub use thread_config::StaticThreadConfigLoader; pub use thread_config::ThreadConfigContext; @@ -106,5 +116,4 @@ pub use thread_config::ThreadConfigLoadErrorCode; pub use thread_config::ThreadConfigLoader; pub use thread_config::ThreadConfigSource; pub use thread_config::UserThreadConfig; - -pub use codex_app_server_protocol::ConfigLayerSource; +pub use toml::Value as TomlValue; diff --git a/codex-rs/core/src/config_loader/README.md b/codex-rs/config/src/loader/README.md similarity index 90% rename from codex-rs/core/src/config_loader/README.md rename to codex-rs/config/src/loader/README.md index 6ee445421f..28750c4929 100644 --- a/codex-rs/core/src/config_loader/README.md +++ b/codex-rs/config/src/loader/README.md @@ -1,4 +1,4 @@ -# `codex-core` config loader +# `codex-config` loader This module is the canonical place to **load and describe Codex configuration layers** (user config, CLI/session overrides, managed config, and MDM-managed preferences) and to produce: @@ -8,9 +8,9 @@ This module is the canonical place to **load and describe Codex configuration la ## Public surface -Exported from `codex_core::config_loader`: +Exported from `codex_config::loader`: -- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, overrides, cloud_requirements, thread_config_loader, host_name) -> ConfigLayerStack` +- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, overrides, cloud_requirements, thread_config_loader) -> ConfigLayerStack` - `ConfigLayerStack` - `effective_config() -> toml::Value` - `origins() -> HashMap` @@ -41,8 +41,10 @@ computing the effective config and origins metadata. This is what Most callers want the effective config plus metadata: ```rust -use codex_core::config_loader::{CloudRequirementsLoader, LoaderOverrides, load_config_layers_state}; use codex_config::NoopThreadConfigLoader; +use codex_config::CloudRequirementsLoader; +use codex_config::LoaderOverrides; +use codex_config::loader::load_config_layers_state; use codex_exec_server::LOCAL_FS; use codex_utils_absolute_path::AbsolutePathBuf; use toml::Value as TomlValue; @@ -57,7 +59,6 @@ let layers = load_config_layers_state( LoaderOverrides::default(), CloudRequirementsLoader::default(), &NoopThreadConfigLoader, - /*host_name*/ None, ).await?; let effective = layers.effective_config(); diff --git a/codex-rs/core/src/config_loader/layer_io.rs b/codex-rs/config/src/loader/layer_io.rs similarity index 95% rename from codex-rs/core/src/config_loader/layer_io.rs rename to codex-rs/config/src/loader/layer_io.rs index 6bd9a9130f..9c15df7271 100644 --- a/codex-rs/core/src/config_loader/layer_io.rs +++ b/codex-rs/config/src/loader/layer_io.rs @@ -1,11 +1,11 @@ -use super::LoaderOverrides; #[cfg(target_os = "macos")] use super::macos::ManagedAdminConfigLayer; #[cfg(target_os = "macos")] use super::macos::load_managed_admin_config_layer; -use codex_config::config_error_from_toml; -use codex_config::io_error_from_config_error; -use codex_exec_server::ExecutorFileSystem; +use crate::diagnostics::config_error_from_toml; +use crate::diagnostics::io_error_from_config_error; +use crate::state::LoaderOverrides; +use codex_file_system::ExecutorFileSystem; use codex_utils_absolute_path::AbsolutePathBuf; use std::io; use std::path::Path; diff --git a/codex-rs/core/src/config_loader/macos.rs b/codex-rs/config/src/loader/macos.rs similarity index 96% rename from codex-rs/core/src/config_loader/macos.rs rename to codex-rs/config/src/loader/macos.rs index 977a09a9c5..3a9fc3a0ea 100644 --- a/codex-rs/core/src/config_loader/macos.rs +++ b/codex-rs/config/src/loader/macos.rs @@ -1,7 +1,7 @@ -use super::ConfigRequirementsToml; -use super::ConfigRequirementsWithSources; -use super::RequirementSource; use super::merge_requirements_with_remote_sandbox_config; +use crate::config_requirements::ConfigRequirementsToml; +use crate::config_requirements::ConfigRequirementsWithSources; +use crate::config_requirements::RequirementSource; use base64::Engine; use base64::prelude::BASE64_STANDARD; use core_foundation::base::TCFType; @@ -65,7 +65,6 @@ fn load_managed_admin_config() -> io::Result> { pub(crate) async fn load_managed_admin_requirements_toml( target: &mut ConfigRequirementsWithSources, override_base64: Option<&str>, - host_name: Option<&str>, ) -> io::Result<()> { if let Some(encoded) = override_base64 { let trimmed = encoded.trim(); @@ -77,7 +76,6 @@ pub(crate) async fn load_managed_admin_requirements_toml( target, managed_preferences_requirements_source(), parse_managed_requirements_base64(trimmed)?, - host_name, ); return Ok(()); } @@ -89,7 +87,6 @@ pub(crate) async fn load_managed_admin_requirements_toml( target, managed_preferences_requirements_source(), requirements, - host_name, ); } Ok(()) diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/config/src/loader/mod.rs similarity index 90% rename from codex-rs/core/src/config_loader/mod.rs rename to codex-rs/config/src/loader/mod.rs index 3b08de32a2..28e5ff3426 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -2,18 +2,30 @@ mod layer_io; #[cfg(target_os = "macos")] mod macos; -#[cfg(test)] -mod tests; - -use crate::config_loader::layer_io::LoadedConfigLayers; +use self::layer_io::LoadedConfigLayers; +use crate::CONFIG_TOML_FILE; +use crate::cloud_requirements::CloudRequirementsLoader; +use crate::config_requirements::ConfigRequirementsToml; +use crate::config_requirements::ConfigRequirementsWithSources; +use crate::config_requirements::RequirementSource; +use crate::config_requirements::SandboxModeRequirement; +use crate::config_toml::ConfigToml; +use crate::config_toml::ProjectConfig; +use crate::diagnostics::ConfigError; +use crate::diagnostics::config_error_from_toml; +use crate::diagnostics::first_layer_config_error_from_entries as typed_first_layer_config_error_from_entries; +use crate::diagnostics::io_error_from_config_error; +use crate::merge::merge_toml_values; +use crate::overrides::build_cli_overrides_layer; +use crate::project_root_markers::default_project_root_markers; +use crate::project_root_markers::project_root_markers_from_config; +use crate::state::ConfigLayerEntry; +use crate::state::ConfigLayerStack; +use crate::state::LoaderOverrides; +use crate::thread_config::ThreadConfigContext; +use crate::thread_config::ThreadConfigLoader; use codex_app_server_protocol::ConfigLayerSource; -use codex_config::CONFIG_TOML_FILE; -use codex_config::ConfigRequirementsWithSources; -use codex_config::ThreadConfigContext; -use codex_config::ThreadConfigLoader; -use codex_config::config_toml::ConfigToml; -use codex_config::config_toml::ProjectConfig; -use codex_exec_server::ExecutorFileSystem; +use codex_file_system::ExecutorFileSystem; use codex_git_utils::resolve_root_git_project_for_trust; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::SandboxMode; @@ -29,67 +41,14 @@ use std::path::Path; use std::path::PathBuf; use toml::Value as TomlValue; -pub use codex_config::AppRequirementToml; -pub use codex_config::AppsRequirementsToml; -pub use codex_config::CloudRequirementsLoadError; -pub use codex_config::CloudRequirementsLoadErrorCode; -pub use codex_config::CloudRequirementsLoader; -pub use codex_config::ConfigError; -pub use codex_config::ConfigLayerEntry; -pub use codex_config::ConfigLayerStack; -pub use codex_config::ConfigLayerStackOrdering; -pub use codex_config::ConfigLoadError; -pub use codex_config::ConfigRequirements; -pub use codex_config::ConfigRequirementsToml; -pub use codex_config::ConstrainedWithSource; -pub use codex_config::FeatureRequirementsToml; -pub use codex_config::FilesystemConstraints; -pub use codex_config::FilesystemDenyReadPattern; -pub use codex_config::LoaderOverrides; -pub use codex_config::McpServerIdentity; -pub use codex_config::McpServerRequirement; -pub use codex_config::NetworkConstraints; -pub use codex_config::NetworkDomainPermissionToml; -pub use codex_config::NetworkDomainPermissionsToml; -pub use codex_config::NetworkRequirementsToml; -pub use codex_config::NetworkUnixSocketPermissionToml; -pub use codex_config::NetworkUnixSocketPermissionsToml; -pub use codex_config::RemoteSandboxConfigToml; -pub use codex_config::RequirementSource; -pub use codex_config::ResidencyRequirement; -pub use codex_config::SandboxModeRequirement; -pub use codex_config::Sourced; -pub use codex_config::TextPosition; -pub use codex_config::TextRange; -pub use codex_config::WebSearchModeRequirement; -pub(crate) use codex_config::build_cli_overrides_layer; -pub(crate) use codex_config::config_error_from_toml; -pub use codex_config::default_project_root_markers; -pub use codex_config::format_config_error; -pub use codex_config::format_config_error_with_source; -pub(crate) use codex_config::io_error_from_config_error; -pub use codex_config::merge_toml_values; -pub use codex_config::project_root_markers_from_config; -#[cfg(test)] -pub(crate) use codex_config::version_for_toml; - -/// On Unix systems, load default settings from this file path, if present. -/// Note that /etc/codex/ is treated as a "config folder," so subfolders such -/// as skills/ and rules/ will also be honored. -pub const SYSTEM_CONFIG_TOML_FILE_UNIX: &str = "/etc/codex/config.toml"; +#[cfg(unix)] +const SYSTEM_CONFIG_TOML_FILE_UNIX: &str = "/etc/codex/config.toml"; #[cfg(windows)] const DEFAULT_PROGRAM_DATA_DIR_WINDOWS: &str = r"C:\ProgramData"; -pub(crate) async fn first_layer_config_error(layers: &ConfigLayerStack) -> Option { - codex_config::first_layer_config_error::(layers, CONFIG_TOML_FILE).await -} - -pub(crate) async fn first_layer_config_error_from_entries( - layers: &[ConfigLayerEntry], -) -> Option { - codex_config::first_layer_config_error_from_entries::(layers, CONFIG_TOML_FILE) - .await +async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> Option { + typed_first_layer_config_error_from_entries::(layers, CONFIG_TOML_FILE).await } /// To build up the set of admin-enforced constraints, we build up from multiple @@ -132,7 +91,6 @@ pub async fn load_config_layers_state( overrides: LoaderOverrides, cloud_requirements: CloudRequirementsLoader, thread_config_loader: &dyn ThreadConfigLoader, - host_name: Option<&str>, ) -> io::Result { let ignore_user_config = overrides.ignore_user_config; let ignore_user_and_project_exec_policy_rules = @@ -144,7 +102,6 @@ pub async fn load_config_layers_state( &mut config_requirements_toml, RequirementSource::CloudRequirements, requirements, - host_name, ); } @@ -154,28 +111,20 @@ pub async fn load_config_layers_state( overrides .macos_managed_config_requirements_base64 .as_deref(), - host_name, ) .await?; // Honor the system requirements.toml location. - let requirements_toml_file = system_requirements_toml_file()?; - load_requirements_toml( - fs, - &mut config_requirements_toml, - &requirements_toml_file, - host_name, - ) - .await?; + let requirements_toml_file = system_requirements_toml_file_with_overrides(&overrides)?; + load_requirements_toml(fs, &mut config_requirements_toml, &requirements_toml_file).await?; // Make a best-effort to support the legacy `managed_config.toml` as a // requirements specification. let loaded_config_layers = - layer_io::load_config_layers_internal(fs, codex_home, overrides).await?; + layer_io::load_config_layers_internal(fs, codex_home, overrides.clone()).await?; load_requirements_from_legacy_scheme( &mut config_requirements_toml, loaded_config_layers.clone(), - host_name, ) .await?; @@ -206,7 +155,7 @@ pub async fn load_config_layers_state( // Include an entry for the "system" config folder, loading its config.toml, // if it exists. - let system_config_toml_file = system_config_toml_file()?; + let system_config_toml_file = system_config_toml_file_with_overrides(&overrides)?; let system_layer = load_config_toml_for_required_layer(fs, &system_config_toml_file, |config_toml| { ConfigLayerEntry::new( @@ -424,11 +373,11 @@ async fn load_config_toml_for_required_layer( /// If available, apply requirements from the platform system /// `requirements.toml` location to `config_requirements_toml` by filling in /// any unset fields. -async fn load_requirements_toml( +#[doc(hidden)] +pub async fn load_requirements_toml( fs: &dyn ExecutorFileSystem, config_requirements_toml: &mut ConfigRequirementsWithSources, requirements_toml_file: &AbsolutePathBuf, - host_name: Option<&str>, ) -> io::Result<()> { match fs .read_file_text(requirements_toml_file, /*sandbox*/ None) @@ -461,7 +410,6 @@ async fn load_requirements_toml( file: requirements_toml_file.clone(), }, requirements_config, - host_name, ); } Err(e) => { @@ -490,16 +438,34 @@ fn system_requirements_toml_file() -> io::Result { windows_system_requirements_toml_file() } +fn system_requirements_toml_file_with_overrides( + overrides: &LoaderOverrides, +) -> io::Result { + match &overrides.system_requirements_path { + Some(path) => AbsolutePathBuf::from_absolute_path(path), + None => system_requirements_toml_file(), + } +} + #[cfg(unix)] -fn system_config_toml_file() -> io::Result { +pub fn system_config_toml_file() -> io::Result { AbsolutePathBuf::from_absolute_path(Path::new(SYSTEM_CONFIG_TOML_FILE_UNIX)) } #[cfg(windows)] -fn system_config_toml_file() -> io::Result { +pub fn system_config_toml_file() -> io::Result { windows_system_config_toml_file() } +fn system_config_toml_file_with_overrides( + overrides: &LoaderOverrides, +) -> io::Result { + match &overrides.system_config_path { + Some(path) => AbsolutePathBuf::from_absolute_path(path), + None => system_config_toml_file(), + } +} + #[cfg(windows)] fn windows_codex_system_dir() -> PathBuf { let program_data = windows_program_data_dir_from_known_folder().unwrap_or_else(|err| { @@ -576,7 +542,6 @@ fn windows_program_data_dir_from_known_folder() -> io::Result { async fn load_requirements_from_legacy_scheme( config_requirements_toml: &mut ConfigRequirementsWithSources, loaded_config_layers: LoadedConfigLayers, - host_name: Option<&str>, ) -> io::Result<()> { // In this implementation, earlier layers cannot be overwritten by later // layers, so list managed_config_from_mdm first because it has the highest @@ -613,7 +578,6 @@ async fn load_requirements_from_legacy_scheme( config_requirements_toml, source, ConfigRequirementsToml::from(legacy_config), - host_name, ); } @@ -624,9 +588,11 @@ pub(super) fn merge_requirements_with_remote_sandbox_config( target: &mut ConfigRequirementsWithSources, source: RequirementSource, mut requirements: ConfigRequirementsToml, - host_name: Option<&str>, ) { - requirements.apply_remote_sandbox_config(host_name); + if requirements.remote_sandbox_config.is_some() { + let host_name = crate::host_name(); + requirements.apply_remote_sandbox_config(host_name.as_deref()); + } target.merge_unset_fields(source, requirements); } @@ -840,7 +806,8 @@ fn project_trust_for_lookup_key( /// /// This ensures that multiple config layers can be merged together correctly /// even if they were loaded from different directories. -pub(crate) fn resolve_relative_paths_in_config_toml( +#[doc(hidden)] +pub fn resolve_relative_paths_in_config_toml( value_from_config_toml: TomlValue, base_dir: &Path, ) -> io::Result { @@ -1025,8 +992,8 @@ async fn load_project_layers( /// /// If present, re-interpret `managed_config.toml` as a `requirements.toml` /// where each specified field is treated as a constraint. Most fields allow -/// only the specified value. `approvals_reviewer = "guardian_subagent"` also -/// allows `user` so people can opt out of the guardian reviewer. +/// only the specified value. `approvals_reviewer = "auto_review"` also allows +/// `user` so people can opt out of the auto-reviewer. #[derive(Deserialize, Debug, Clone, Default, PartialEq)] struct LegacyManagedConfigToml { approval_policy: Option, @@ -1048,7 +1015,7 @@ impl From for ConfigRequirementsToml { } if let Some(approvals_reviewer) = approvals_reviewer { let mut allowed_reviewers = vec![approvals_reviewer]; - if approvals_reviewer == ApprovalsReviewer::GuardianSubagent { + if approvals_reviewer == ApprovalsReviewer::AutoReview { allowed_reviewers.push(ApprovalsReviewer::User); } config_requirements_toml.allowed_approvals_reviewers = Some(allowed_reviewers); @@ -1135,7 +1102,7 @@ foo = "xyzzy" fn legacy_managed_config_backfill_allows_user_when_guardian_is_required() { let legacy = LegacyManagedConfigToml { approval_policy: None, - approvals_reviewer: Some(ApprovalsReviewer::GuardianSubagent), + approvals_reviewer: Some(ApprovalsReviewer::AutoReview), sandbox_mode: None, }; @@ -1143,10 +1110,7 @@ foo = "xyzzy" assert_eq!( requirements.allowed_approvals_reviewers, - Some(vec![ - ApprovalsReviewer::GuardianSubagent, - ApprovalsReviewer::User - ]) + Some(vec![ApprovalsReviewer::AutoReview, ApprovalsReviewer::User,]) ); } diff --git a/codex-rs/config/src/mcp_types.rs b/codex-rs/config/src/mcp_types.rs index a276cd7070..d642d9fc57 100644 --- a/codex-rs/config/src/mcp_types.rs +++ b/codex-rs/config/src/mcp_types.rs @@ -176,7 +176,11 @@ pub struct McpServerConfig { pub tools: HashMap, } -/// Raw MCP config shape used for deserialization and JSON Schema generation. +/// Raw MCP config shape used for deserialization and supported-field JSON +/// Schema generation. +/// +/// Fields that are accepted only to produce targeted validation errors should +/// be skipped in the generated schema. /// /// Keep `TryFrom for McpServerConfig` exhaustively /// destructuring this struct so new TOML fields cannot be added here without @@ -200,6 +204,7 @@ pub struct RawMcpServerConfig { // streamable_http pub url: Option, + #[schemars(skip)] pub bearer_token: Option, pub bearer_token_env_var: Option, diff --git a/codex-rs/config/src/profile_toml.rs b/codex-rs/config/src/profile_toml.rs index 69215c0440..f6f63191b5 100644 --- a/codex-rs/config/src/profile_toml.rs +++ b/codex-rs/config/src/profile_toml.rs @@ -23,6 +23,7 @@ use codex_protocol::protocol::AskForApproval; #[schemars(deny_unknown_fields)] pub struct ConfigProfile { pub model: Option, + /// Optional explicit service tier preference for new turns (`fast` or `flex`). pub service_tier: Option, /// The key in the `model_providers` map identifying the /// [`ModelProviderInfo`] to use. @@ -40,8 +41,11 @@ pub struct ConfigProfile { pub chatgpt_base_url: Option, /// Optional path to a file containing model instructions. pub model_instructions_file: Option, + /// Deprecated: ignored. + #[schemars(skip)] pub js_repl_node_path: Option, - /// Ordered list of directories to search for Node modules in `js_repl`. + /// Deprecated: ignored. + #[schemars(skip)] pub js_repl_node_module_dirs: Option>, /// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution. pub zsh_path: Option, diff --git a/codex-rs/config/src/state.rs b/codex-rs/config/src/state.rs index 92f36509f6..6bb846edd9 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -18,6 +18,8 @@ use toml::Value as TomlValue; #[derive(Debug, Default, Clone)] pub struct LoaderOverrides { pub managed_config_path: Option, + pub system_config_path: Option, + pub system_requirements_path: Option, pub ignore_user_config: bool, pub ignore_user_and_project_exec_policy_rules: bool, //TODO(gt): Add a macos_ prefix to this field and remove the target_os check. @@ -31,11 +33,17 @@ impl LoaderOverrides { /// /// This is intended for tests that should load only repo-controlled config fixtures. pub fn without_managed_config_for_tests() -> Self { - Self::with_managed_config_path_for_tests( - std::env::temp_dir() - .join("codex-config-tests") - .join("managed_config.toml"), - ) + let base = std::env::temp_dir().join("codex-config-tests"); + Self { + managed_config_path: Some(base.join("managed_config.toml")), + system_config_path: Some(base.join("config.toml")), + system_requirements_path: Some(base.join("requirements.toml")), + ignore_user_config: false, + ignore_user_and_project_exec_policy_rules: false, + #[cfg(target_os = "macos")] + managed_preferences_base64: Some(String::new()), + macos_managed_config_requirements_base64: Some(String::new()), + } } /// Returns overrides with host MDM disabled and managed config loaded from `managed_config_path`. @@ -44,11 +52,7 @@ impl LoaderOverrides { pub fn with_managed_config_path_for_tests(managed_config_path: PathBuf) -> Self { Self { managed_config_path: Some(managed_config_path), - ignore_user_config: false, - ignore_user_and_project_exec_policy_rules: false, - #[cfg(target_os = "macos")] - managed_preferences_base64: Some(String::new()), - macos_managed_config_requirements_base64: Some(String::new()), + ..Self::without_managed_config_for_tests() } } } diff --git a/codex-rs/config/src/thread_config.rs b/codex-rs/config/src/thread_config.rs index e1e598708e..1b3ea8fe87 100644 --- a/codex-rs/config/src/thread_config.rs +++ b/codex-rs/config/src/thread_config.rs @@ -10,6 +10,10 @@ use toml::Value as TomlValue; use crate::ConfigLayerEntry; +mod remote; + +pub use remote::RemoteThreadConfigLoader; + /// Context available to implementations when loading thread-scoped config. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct ThreadConfigContext { diff --git a/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.proto b/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.proto new file mode 100644 index 0000000000..1efccfd1bf --- /dev/null +++ b/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.proto @@ -0,0 +1,68 @@ +syntax = "proto3"; + +package codex.thread_config.v1; + +service ThreadConfigLoader { + rpc Load(LoadThreadConfigRequest) returns (LoadThreadConfigResponse); +} + +message LoadThreadConfigRequest { + optional string thread_id = 1; + optional string cwd = 2; +} + +message LoadThreadConfigResponse { + repeated ThreadConfigSource sources = 1; +} + +message ThreadConfigSource { + oneof source { + SessionThreadConfig session = 1; + UserThreadConfig user = 2; + } +} + +message SessionThreadConfig { + optional string model_provider = 1; + repeated ModelProvider model_providers = 2; + map features = 3; +} + +message UserThreadConfig {} + +message ModelProvider { + string id = 1; + string name = 2; + optional string base_url = 3; + optional string env_key = 4; + optional string env_key_instructions = 5; + optional string experimental_bearer_token = 6; + optional ModelProviderAuthInfo auth = 7; + WireApi wire_api = 8; + optional StringMap query_params = 9; + optional StringMap http_headers = 10; + optional StringMap env_http_headers = 11; + optional uint64 request_max_retries = 12; + optional uint64 stream_max_retries = 13; + optional uint64 stream_idle_timeout_ms = 14; + optional uint64 websocket_connect_timeout_ms = 15; + bool requires_openai_auth = 16; + bool supports_websockets = 17; +} + +message StringMap { + map values = 1; +} + +message ModelProviderAuthInfo { + string command = 1; + repeated string args = 2; + uint64 timeout_ms = 3; + uint64 refresh_interval_ms = 4; + string cwd = 5; +} + +enum WireApi { + WIRE_API_UNSPECIFIED = 0; + WIRE_API_RESPONSES = 1; +} diff --git a/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.rs b/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.rs new file mode 100644 index 0000000000..30a76bc6b2 --- /dev/null +++ b/codex-rs/config/src/thread_config/proto/codex.thread_config.v1.rs @@ -0,0 +1,400 @@ +// This file is @generated by prost-build. +#![allow(clippy::trivially_copy_pass_by_ref)] + +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct LoadThreadConfigRequest { + #[prost(string, optional, tag = "1")] + pub thread_id: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "2")] + pub cwd: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct LoadThreadConfigResponse { + #[prost(message, repeated, tag = "1")] + pub sources: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ThreadConfigSource { + #[prost(oneof = "thread_config_source::Source", tags = "1, 2")] + pub source: ::core::option::Option, +} +/// Nested message and enum types in `ThreadConfigSource`. +pub mod thread_config_source { + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Source { + #[prost(message, tag = "1")] + Session(super::SessionThreadConfig), + #[prost(message, tag = "2")] + User(super::UserThreadConfig), + } +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SessionThreadConfig { + #[prost(string, optional, tag = "1")] + pub model_provider: ::core::option::Option<::prost::alloc::string::String>, + #[prost(message, repeated, tag = "2")] + pub model_providers: ::prost::alloc::vec::Vec, + #[prost(map = "string, bool", tag = "3")] + pub features: ::std::collections::HashMap<::prost::alloc::string::String, bool>, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct UserThreadConfig {} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ModelProvider { + #[prost(string, tag = "1")] + pub id: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub name: ::prost::alloc::string::String, + #[prost(string, optional, tag = "3")] + pub base_url: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "4")] + pub env_key: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "5")] + pub env_key_instructions: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag = "6")] + pub experimental_bearer_token: ::core::option::Option<::prost::alloc::string::String>, + #[prost(message, optional, tag = "7")] + pub auth: ::core::option::Option, + #[prost(enumeration = "WireApi", tag = "8")] + pub wire_api: i32, + #[prost(message, optional, tag = "9")] + pub query_params: ::core::option::Option, + #[prost(message, optional, tag = "10")] + pub http_headers: ::core::option::Option, + #[prost(message, optional, tag = "11")] + pub env_http_headers: ::core::option::Option, + #[prost(uint64, optional, tag = "12")] + pub request_max_retries: ::core::option::Option, + #[prost(uint64, optional, tag = "13")] + pub stream_max_retries: ::core::option::Option, + #[prost(uint64, optional, tag = "14")] + pub stream_idle_timeout_ms: ::core::option::Option, + #[prost(uint64, optional, tag = "15")] + pub websocket_connect_timeout_ms: ::core::option::Option, + #[prost(bool, tag = "16")] + pub requires_openai_auth: bool, + #[prost(bool, tag = "17")] + pub supports_websockets: bool, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct StringMap { + #[prost(map = "string, string", tag = "1")] + pub values: + ::std::collections::HashMap<::prost::alloc::string::String, ::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ModelProviderAuthInfo { + #[prost(string, tag = "1")] + pub command: ::prost::alloc::string::String, + #[prost(string, repeated, tag = "2")] + pub args: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(uint64, tag = "3")] + pub timeout_ms: u64, + #[prost(uint64, tag = "4")] + pub refresh_interval_ms: u64, + #[prost(string, tag = "5")] + pub cwd: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum WireApi { + Unspecified = 0, + Responses = 1, +} +impl WireApi { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "WIRE_API_UNSPECIFIED", + Self::Responses => "WIRE_API_RESPONSES", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "WIRE_API_UNSPECIFIED" => Some(Self::Unspecified), + "WIRE_API_RESPONSES" => Some(Self::Responses), + _ => None, + } + } +} +/// Generated client implementations. +pub mod thread_config_loader_client { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value + )] + use tonic::codegen::http::Uri; + use tonic::codegen::*; + #[derive(Debug, Clone)] + pub struct ThreadConfigLoaderClient { + inner: tonic::client::Grpc, + } + impl ThreadConfigLoaderClient { + /// Attempt to create a new client by connecting to a given endpoint. + pub async fn connect(dst: D) -> Result + where + D: TryInto, + D::Error: Into, + { + let conn = tonic::transport::Endpoint::new(dst)?.connect().await?; + Ok(Self::new(conn)) + } + } + impl ThreadConfigLoaderClient + where + T: tonic::client::GrpcService, + T::Error: Into, + T::ResponseBody: Body + std::marker::Send + 'static, + ::Error: Into + std::marker::Send, + { + pub fn new(inner: T) -> Self { + let inner = tonic::client::Grpc::new(inner); + Self { inner } + } + pub fn with_origin(inner: T, origin: Uri) -> Self { + let inner = tonic::client::Grpc::with_origin(inner, origin); + Self { inner } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> ThreadConfigLoaderClient> + where + F: tonic::service::Interceptor, + T::ResponseBody: Default, + T: tonic::codegen::Service< + http::Request, + Response = http::Response< + >::ResponseBody, + >, + >, + >>::Error: + Into + std::marker::Send + std::marker::Sync, + { + ThreadConfigLoaderClient::new(InterceptedService::new(inner, interceptor)) + } + /// Compress requests with the given encoding. + /// + /// This requires the server to support it otherwise it might respond with an + /// error. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.send_compressed(encoding); + self + } + /// Enable decompressing responses. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.accept_compressed(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_decoding_message_size(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_encoding_message_size(limit); + self + } + pub async fn load( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result, tonic::Status> + { + self.inner.ready().await.map_err(|e| { + tonic::Status::unknown(format!("Service was not ready: {}", e.into())) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/codex.thread_config.v1.ThreadConfigLoader/Load", + ); + let mut req = request.into_request(); + req.extensions_mut().insert(GrpcMethod::new( + "codex.thread_config.v1.ThreadConfigLoader", + "Load", + )); + self.inner.unary(req, path, codec).await + } + } +} +/// Generated server implementations. +pub mod thread_config_loader_server { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value + )] + use tonic::codegen::*; + /// Generated trait containing gRPC methods that should be implemented for use with ThreadConfigLoaderServer. + #[async_trait] + pub trait ThreadConfigLoader: std::marker::Send + std::marker::Sync + 'static { + async fn load( + &self, + request: tonic::Request, + ) -> std::result::Result, tonic::Status>; + } + #[derive(Debug)] + pub struct ThreadConfigLoaderServer { + inner: Arc, + accept_compression_encodings: EnabledCompressionEncodings, + send_compression_encodings: EnabledCompressionEncodings, + max_decoding_message_size: Option, + max_encoding_message_size: Option, + } + impl ThreadConfigLoaderServer { + pub fn new(inner: T) -> Self { + Self::from_arc(Arc::new(inner)) + } + pub fn from_arc(inner: Arc) -> Self { + Self { + inner, + accept_compression_encodings: Default::default(), + send_compression_encodings: Default::default(), + max_decoding_message_size: None, + max_encoding_message_size: None, + } + } + pub fn with_interceptor(inner: T, interceptor: F) -> InterceptedService + where + F: tonic::service::Interceptor, + { + InterceptedService::new(Self::new(inner), interceptor) + } + /// Enable decompressing requests with the given encoding. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.accept_compression_encodings.enable(encoding); + self + } + /// Compress responses with the given encoding, if the client supports it. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.send_compression_encodings.enable(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.max_decoding_message_size = Some(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.max_encoding_message_size = Some(limit); + self + } + } + impl tonic::codegen::Service> for ThreadConfigLoaderServer + where + T: ThreadConfigLoader, + B: Body + std::marker::Send + 'static, + B::Error: Into + std::marker::Send + 'static, + { + type Response = http::Response; + type Error = std::convert::Infallible; + type Future = BoxFuture; + fn poll_ready( + &mut self, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + fn call(&mut self, req: http::Request) -> Self::Future { + match req.uri().path() { + "/codex.thread_config.v1.ThreadConfigLoader/Load" => { + #[allow(non_camel_case_types)] + struct LoadSvc(pub Arc); + impl + tonic::server::UnaryService for LoadSvc + { + type Response = super::LoadThreadConfigResponse; + type Future = BoxFuture, tonic::Status>; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::load(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = LoadSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + _ => Box::pin(async move { + let mut response = http::Response::new(tonic::body::Body::default()); + let headers = response.headers_mut(); + headers.insert( + tonic::Status::GRPC_STATUS, + (tonic::Code::Unimplemented as i32).into(), + ); + headers.insert( + http::header::CONTENT_TYPE, + tonic::metadata::GRPC_CONTENT_TYPE, + ); + Ok(response) + }), + } + } + } + impl Clone for ThreadConfigLoaderServer { + fn clone(&self) -> Self { + let inner = self.inner.clone(); + Self { + inner, + accept_compression_encodings: self.accept_compression_encodings, + send_compression_encodings: self.send_compression_encodings, + max_decoding_message_size: self.max_decoding_message_size, + max_encoding_message_size: self.max_encoding_message_size, + } + } + } + /// Generated gRPC service name + pub const SERVICE_NAME: &str = "codex.thread_config.v1.ThreadConfigLoader"; + impl tonic::server::NamedService for ThreadConfigLoaderServer { + const NAME: &'static str = SERVICE_NAME; + } +} diff --git a/codex-rs/config/src/thread_config/remote.rs b/codex-rs/config/src/thread_config/remote.rs new file mode 100644 index 0000000000..7b7feacec5 --- /dev/null +++ b/codex-rs/config/src/thread_config/remote.rs @@ -0,0 +1,523 @@ +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::num::NonZeroU64; +use std::time::Duration; + +use async_trait::async_trait; +use codex_model_provider_info::ModelProviderInfo; +use codex_model_provider_info::WireApi; +use codex_protocol::config_types::ModelProviderAuthInfo; +use codex_utils_absolute_path::AbsolutePathBuf; + +use super::SessionThreadConfig; +use super::ThreadConfigContext; +use super::ThreadConfigLoadError; +use super::ThreadConfigLoadErrorCode; +use super::ThreadConfigLoader; +use super::ThreadConfigSource; +use super::UserThreadConfig; +use proto::thread_config_loader_client::ThreadConfigLoaderClient; + +#[path = "proto/codex.thread_config.v1.rs"] +mod proto; + +const REMOTE_THREAD_CONFIG_LOAD_TIMEOUT: Duration = Duration::from_secs(5); + +/// gRPC-backed [`ThreadConfigLoader`] implementation. +#[derive(Clone, Debug)] +pub struct RemoteThreadConfigLoader { + endpoint: String, +} + +impl RemoteThreadConfigLoader { + pub fn new(endpoint: impl Into) -> Self { + Self { + endpoint: endpoint.into(), + } + } + + async fn client( + &self, + ) -> Result, ThreadConfigLoadError> { + ThreadConfigLoaderClient::connect(self.endpoint.clone()) + .await + .map_err(|err| { + ThreadConfigLoadError::new( + ThreadConfigLoadErrorCode::RequestFailed, + /*status_code*/ None, + format!("failed to connect to remote thread config loader: {err}"), + ) + }) + } +} + +#[async_trait] +impl ThreadConfigLoader for RemoteThreadConfigLoader { + async fn load( + &self, + context: ThreadConfigContext, + ) -> Result, ThreadConfigLoadError> { + let response = self + .client() + .await? + .load(load_thread_config_request(context)) + .await + .map_err(remote_status_to_error)? + .into_inner(); + + response + .sources + .into_iter() + .map(thread_config_source_from_proto) + .collect() + } +} + +fn load_thread_config_request( + context: ThreadConfigContext, +) -> tonic::Request { + let mut request = tonic::Request::new(proto::LoadThreadConfigRequest { + thread_id: context.thread_id, + cwd: context.cwd.map(|cwd| cwd.to_string_lossy().into_owned()), + }); + request.set_timeout(REMOTE_THREAD_CONFIG_LOAD_TIMEOUT); + request +} + +fn remote_status_to_error(status: tonic::Status) -> ThreadConfigLoadError { + let code = match status.code() { + tonic::Code::Unauthenticated | tonic::Code::PermissionDenied => { + ThreadConfigLoadErrorCode::Auth + } + tonic::Code::DeadlineExceeded => ThreadConfigLoadErrorCode::Timeout, + tonic::Code::Ok + | tonic::Code::Cancelled + | tonic::Code::Unknown + | tonic::Code::InvalidArgument + | tonic::Code::NotFound + | tonic::Code::AlreadyExists + | tonic::Code::ResourceExhausted + | tonic::Code::FailedPrecondition + | tonic::Code::Aborted + | tonic::Code::OutOfRange + | tonic::Code::Unimplemented + | tonic::Code::Internal + | tonic::Code::Unavailable + | tonic::Code::DataLoss => ThreadConfigLoadErrorCode::RequestFailed, + }; + ThreadConfigLoadError::new( + code, + /*status_code*/ None, + format!("remote thread config request failed: {status}"), + ) +} + +fn thread_config_source_from_proto( + source: proto::ThreadConfigSource, +) -> Result { + match source.source { + Some(proto::thread_config_source::Source::Session(config)) => { + session_thread_config_from_proto(config).map(ThreadConfigSource::Session) + } + Some(proto::thread_config_source::Source::User(_)) => { + Ok(ThreadConfigSource::User(UserThreadConfig::default())) + } + None => Err(parse_error("remote thread config omitted source payload")), + } +} + +fn session_thread_config_from_proto( + config: proto::SessionThreadConfig, +) -> Result { + let model_providers = config + .model_providers + .into_iter() + .map(model_provider_from_proto) + .collect::, _>>()?; + + Ok(SessionThreadConfig { + model_provider: config.model_provider, + model_providers, + features: config.features.into_iter().collect::>(), + }) +} + +fn model_provider_from_proto( + provider: proto::ModelProvider, +) -> Result<(String, ModelProviderInfo), ThreadConfigLoadError> { + if provider.id.is_empty() { + return Err(parse_error( + "remote thread config returned model provider without an id", + )); + } + let id = provider.id; + let wire_api = match proto::WireApi::try_from(provider.wire_api) { + Ok(proto::WireApi::Responses) => WireApi::Responses, + Ok(proto::WireApi::Unspecified) => { + return Err(parse_error("remote thread config omitted wire_api")); + } + Err(_) => { + return Err(parse_error(format!( + "remote thread config returned unknown wire_api: {}", + provider.wire_api + ))); + } + }; + let info = ModelProviderInfo { + name: provider.name, + base_url: provider.base_url, + env_key: provider.env_key, + env_key_instructions: provider.env_key_instructions, + experimental_bearer_token: provider.experimental_bearer_token, + auth: provider + .auth + .map(model_provider_auth_from_proto) + .transpose()?, + aws: None, + wire_api, + query_params: provider.query_params.map(|map| map.values), + http_headers: provider.http_headers.map(|map| map.values), + env_http_headers: provider.env_http_headers.map(|map| map.values), + request_max_retries: provider.request_max_retries, + stream_max_retries: provider.stream_max_retries, + stream_idle_timeout_ms: provider.stream_idle_timeout_ms, + websocket_connect_timeout_ms: provider.websocket_connect_timeout_ms, + requires_openai_auth: provider.requires_openai_auth, + supports_websockets: provider.supports_websockets, + }; + Ok((id, info)) +} + +#[cfg(test)] +fn model_provider_to_proto( + id: impl Into, + provider: ModelProviderInfo, +) -> proto::ModelProvider { + let ModelProviderInfo { + name, + base_url, + env_key, + env_key_instructions, + experimental_bearer_token, + auth, + aws: _, + wire_api, + query_params, + http_headers, + env_http_headers, + request_max_retries, + stream_max_retries, + stream_idle_timeout_ms, + websocket_connect_timeout_ms, + requires_openai_auth, + supports_websockets, + } = provider; + + proto::ModelProvider { + id: id.into(), + name, + base_url, + env_key, + env_key_instructions, + experimental_bearer_token, + auth: auth.map(model_provider_auth_to_proto), + wire_api: proto_wire_api(wire_api).into(), + query_params: query_params.map(proto_string_map), + http_headers: http_headers.map(proto_string_map), + env_http_headers: env_http_headers.map(proto_string_map), + request_max_retries, + stream_max_retries, + stream_idle_timeout_ms, + websocket_connect_timeout_ms, + requires_openai_auth, + supports_websockets, + } +} + +fn model_provider_auth_from_proto( + auth: proto::ModelProviderAuthInfo, +) -> Result { + let timeout_ms = NonZeroU64::new(auth.timeout_ms) + .ok_or_else(|| parse_error("remote thread config returned zero auth timeout_ms"))?; + let cwd = AbsolutePathBuf::from_absolute_path_checked(&auth.cwd).map_err(|err| { + parse_error(format!( + "remote thread config returned invalid auth cwd {:?}: {err}", + auth.cwd + )) + })?; + + Ok(ModelProviderAuthInfo { + command: auth.command, + args: auth.args, + timeout_ms, + refresh_interval_ms: auth.refresh_interval_ms, + cwd, + }) +} + +#[cfg(test)] +fn model_provider_auth_to_proto(auth: ModelProviderAuthInfo) -> proto::ModelProviderAuthInfo { + let ModelProviderAuthInfo { + command, + args, + timeout_ms, + refresh_interval_ms, + cwd, + } = auth; + + proto::ModelProviderAuthInfo { + command, + args, + timeout_ms: timeout_ms.get(), + refresh_interval_ms, + cwd: cwd.to_string_lossy().into_owned(), + } +} + +#[cfg(test)] +fn proto_string_map(values: HashMap) -> proto::StringMap { + proto::StringMap { values } +} + +#[cfg(test)] +fn proto_wire_api(wire_api: WireApi) -> proto::WireApi { + match wire_api { + WireApi::Responses => proto::WireApi::Responses, + } +} + +fn parse_error(message: impl Into) -> ThreadConfigLoadError { + ThreadConfigLoadError::new( + ThreadConfigLoadErrorCode::Parse, + /*status_code*/ None, + message.into(), + ) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::collections::HashMap; + use std::num::NonZeroU64; + + use codex_model_provider_info::ModelProviderInfo; + use codex_model_provider_info::WireApi; + use codex_protocol::config_types::ModelProviderAuthInfo; + use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; + use tonic::Request; + use tonic::Response; + use tonic::Status; + use tonic::transport::Server; + + use super::proto::thread_config_loader_server; + use super::proto::thread_config_loader_server::ThreadConfigLoaderServer; + use super::*; + use crate::SessionThreadConfig; + use crate::UserThreadConfig; + + struct TestServer { + sources: Vec, + expected_cwd: String, + } + + #[tonic::async_trait] + impl thread_config_loader_server::ThreadConfigLoader for TestServer { + async fn load( + &self, + request: Request, + ) -> Result, Status> { + assert_eq!( + request.into_inner(), + proto::LoadThreadConfigRequest { + thread_id: Some("thread-1".to_string()), + cwd: Some(self.expected_cwd.clone()), + } + ); + + Ok(Response::new(proto::LoadThreadConfigResponse { + sources: self.sources.clone(), + })) + } + } + + #[tokio::test] + async fn load_thread_config_calls_remote_service() { + let cwd = workspace_dir().join("project"); + let expected_cwd = cwd.to_string_lossy().into_owned(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test server"); + let addr = listener.local_addr().expect("test server addr"); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + Server::builder() + .add_service(ThreadConfigLoaderServer::new(TestServer { + sources: proto_sources(), + expected_cwd, + })) + .serve_with_incoming_shutdown( + tokio_stream::wrappers::TcpListenerStream::new(listener), + async { + let _ = shutdown_rx.await; + }, + ) + .await + }); + + let loader = RemoteThreadConfigLoader::new(format!("http://{addr}")); + let loaded = loader + .load(ThreadConfigContext { + thread_id: Some("thread-1".to_string()), + cwd: Some(cwd), + }) + .await; + + let _ = shutdown_tx.send(()); + server.await.expect("join server").expect("server"); + + assert_eq!(loaded.expect("load thread config"), expected_sources()); + } + + #[test] + fn load_thread_config_request_sets_timeout() { + let request = load_thread_config_request(ThreadConfigContext::default()); + + assert_eq!( + request + .metadata() + .get("grpc-timeout") + .and_then(|value| value.to_str().ok()), + Some("5000000u") + ); + } + + #[test] + fn model_provider_proto_roundtrips_through_domain_type() { + let expected = expected_provider(); + let proto = model_provider_to_proto("local", expected.clone()); + let (id, actual) = model_provider_from_proto(proto).expect("model provider from proto"); + + assert_eq!(id, "local"); + assert_eq!(actual, expected); + } + + fn proto_sources() -> Vec { + let workspace_cwd = workspace_dir().to_string_lossy().into_owned(); + vec![ + proto::ThreadConfigSource { + source: Some(proto::thread_config_source::Source::Session( + proto::SessionThreadConfig { + model_provider: Some("local".to_string()), + model_providers: vec![proto::ModelProvider { + id: "local".to_string(), + name: "Local".to_string(), + base_url: Some("http://127.0.0.1:8061/api/codex".to_string()), + env_key: None, + env_key_instructions: None, + experimental_bearer_token: None, + auth: Some(proto::ModelProviderAuthInfo { + command: "token-helper".to_string(), + args: vec!["--json".to_string()], + timeout_ms: 5_000, + refresh_interval_ms: 300_000, + cwd: workspace_cwd, + }), + wire_api: proto::WireApi::Responses.into(), + query_params: Some(proto::StringMap { + values: HashMap::from([( + "api-version".to_string(), + "2026-04-16".to_string(), + )]), + }), + http_headers: Some(proto::StringMap { + values: HashMap::from([( + "X-Test".to_string(), + "enabled".to_string(), + )]), + }), + env_http_headers: Some(proto::StringMap { + values: HashMap::from([( + "X-Env".to_string(), + "LOCAL_HEADER".to_string(), + )]), + }), + request_max_retries: Some(7), + stream_max_retries: Some(8), + stream_idle_timeout_ms: Some(9_000), + websocket_connect_timeout_ms: Some(10_000), + requires_openai_auth: false, + supports_websockets: true, + }], + features: HashMap::from([ + ("plugins".to_string(), false), + ("tools".to_string(), true), + ]), + }, + )), + }, + proto::ThreadConfigSource { + source: Some(proto::thread_config_source::Source::User( + proto::UserThreadConfig {}, + )), + }, + ] + } + + fn expected_sources() -> Vec { + vec![ + ThreadConfigSource::Session(SessionThreadConfig { + model_provider: Some("local".to_string()), + model_providers: HashMap::from([("local".to_string(), expected_provider())]), + features: BTreeMap::from([ + ("plugins".to_string(), false), + ("tools".to_string(), true), + ]), + }), + ThreadConfigSource::User(UserThreadConfig::default()), + ] + } + + fn expected_provider() -> ModelProviderInfo { + ModelProviderInfo { + name: "Local".to_string(), + base_url: Some("http://127.0.0.1:8061/api/codex".to_string()), + env_key: None, + env_key_instructions: None, + experimental_bearer_token: None, + auth: Some(ModelProviderAuthInfo { + command: "token-helper".to_string(), + args: vec!["--json".to_string()], + timeout_ms: NonZeroU64::new(5_000).expect("non-zero timeout"), + refresh_interval_ms: 300_000, + cwd: workspace_dir(), + }), + wire_api: WireApi::Responses, + query_params: Some(HashMap::from([( + "api-version".to_string(), + "2026-04-16".to_string(), + )])), + http_headers: Some(HashMap::from([( + "X-Test".to_string(), + "enabled".to_string(), + )])), + env_http_headers: Some(HashMap::from([( + "X-Env".to_string(), + "LOCAL_HEADER".to_string(), + )])), + request_max_retries: Some(7), + stream_max_retries: Some(8), + stream_idle_timeout_ms: Some(9_000), + websocket_connect_timeout_ms: Some(10_000), + requires_openai_auth: false, + supports_websockets: true, + aws: None, + } + } + + fn workspace_dir() -> AbsolutePathBuf { + AbsolutePathBuf::current_dir() + .expect("current dir") + .join("workspace") + } +} diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 50a24db534..1b9066d07f 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -12,15 +12,17 @@ pub use crate::mcp_types::McpServerTransportConfig; pub use crate::mcp_types::RawMcpServerConfig; pub use codex_protocol::config_types::AltScreenMode; pub use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::EnvironmentVariablePattern; pub use codex_protocol::config_types::ModeKind; pub use codex_protocol::config_types::Personality; pub use codex_protocol::config_types::ServiceTier; +use codex_protocol::config_types::ShellEnvironmentPolicy; +use codex_protocol::config_types::ShellEnvironmentPolicyInherit; pub use codex_protocol::config_types::WebSearchMode; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::BTreeMap; use std::collections::HashMap; use std::fmt; -use wildmatch::WildMatchPattern; use schemars::JsonSchema; use serde::Deserialize; @@ -532,6 +534,9 @@ pub struct ModelAvailabilityNuxConfig { pub shown_count: HashMap, } +/// Fallback resize-reflow row cap when Codex cannot identify a terminal-specific scrollback size. +pub const DEFAULT_TERMINAL_RESIZE_REFLOW_FALLBACK_MAX_ROWS: usize = 1_000; + /// Collection of settings that are specific to the TUI. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] @@ -570,7 +575,9 @@ pub struct Tui { /// Ordered list of terminal title item identifiers. /// /// When set, the TUI renders the selected items into the terminal window/tab title. - /// When unset, the TUI defaults to: `spinner` and `project`. + /// When unset, the TUI defaults to: `activity` and `project`. + /// The `activity` item spins while working and shows an action-required + /// message when blocked on the user. #[serde(default)] pub terminal_title: Option>, @@ -584,6 +591,13 @@ pub struct Tui { /// Startup tooltip availability NUX state persisted by the TUI. #[serde(default)] pub model_availability_nux: ModelAvailabilityNuxConfig, + + /// Trim terminal resize-reflow replay to the most recent rendered terminal rows when the + /// transcript exceeds this cap. Omit to use Codex's terminal-specific default. Set to `0` to + /// keep all rendered rows. + #[serde(default)] + #[schemars(range(min = 0))] + pub terminal_resize_reflow_max_rows: Option, } const fn default_true() -> bool { @@ -615,6 +629,8 @@ pub struct Notice { pub hide_full_access_warning: Option, /// Tracks whether the user has acknowledged the Windows world-writable directories warning. pub hide_world_writable_warning: Option, + /// Tracks whether the user opted out of Codex-managed fast defaults. + pub fast_default_opt_out: Option, /// Tracks whether the user opted out of the rate limit model switch reminder. pub hide_rate_limit_model_nudge: Option, /// Tracks whether the user has seen the model migration prompt @@ -695,21 +711,6 @@ impl From for codex_app_server_protocol::SandboxSettings } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] -#[serde(rename_all = "kebab-case")] -pub enum ShellEnvironmentPolicyInherit { - /// "Core" environment variables for the platform. On UNIX, this would - /// include HOME, LOGNAME, PATH, SHELL, and USER, among others. - Core, - - /// Inherits the full environment from the parent process. - #[default] - All, - - /// Do not inherit any environment variables from the parent process. - None, -} - /// Policy for building the `env` when spawning a process via either the /// `shell` or `local_shell` tool. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] @@ -730,37 +731,6 @@ pub struct ShellEnvironmentPolicyToml { pub experimental_use_profile: Option, } -pub type EnvironmentVariablePattern = WildMatchPattern<'*', '?'>; - -/// Deriving the `env` based on this policy works as follows: -/// 1. Create an initial map based on the `inherit` policy. -/// 2. If `ignore_default_excludes` is false, filter the map using the default -/// exclude pattern(s), which are: `"*KEY*"`, `"*SECRET*"`, and `"*TOKEN*"`. -/// 3. If `exclude` is not empty, filter the map using the provided patterns. -/// 4. Insert any entries from `r#set` into the map. -/// 5. If non-empty, filter the map using the `include_only` patterns. -#[derive(Debug, Clone, PartialEq)] -pub struct ShellEnvironmentPolicy { - /// Starting point when building the environment. - pub inherit: ShellEnvironmentPolicyInherit, - - /// True to skip the check to exclude default environment variables that - /// contain "KEY", "SECRET", or "TOKEN" in their name. Defaults to true. - pub ignore_default_excludes: bool, - - /// Environment variable names to exclude from the environment. - pub exclude: Vec, - - /// (key, value) pairs to insert in the environment. - pub r#set: HashMap, - - /// Environment variable names to retain in the environment. - pub include_only: Vec, - - /// If true, the shell profile will be used to run the command. - pub use_profile: bool, -} - impl From for ShellEnvironmentPolicy { fn from(toml: ShellEnvironmentPolicyToml) -> Self { // Default to inheriting the full environment when not specified. @@ -792,19 +762,6 @@ impl From for ShellEnvironmentPolicy { } } -impl Default for ShellEnvironmentPolicy { - fn default() -> Self { - Self { - inherit: ShellEnvironmentPolicyInherit::All, - ignore_default_excludes: true, - exclude: Vec::new(), - r#set: HashMap::new(), - include_only: Vec::new(), - use_profile: false, - } - } -} - #[cfg(test)] #[path = "types_tests.rs"] mod tests; diff --git a/codex-rs/core-plugins/Cargo.toml b/codex-rs/core-plugins/Cargo.toml index 0372d9a14a..8a0e4f7720 100644 --- a/codex-rs/core-plugins/Cargo.toml +++ b/codex-rs/core-plugins/Cargo.toml @@ -19,6 +19,8 @@ codex-core-skills = { workspace = true } codex-exec-server = { workspace = true } codex-git-utils = { workspace = true } codex-login = { workspace = true } +codex-model-provider = { workspace = true } +codex-otel = { workspace = true } codex-plugin = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } @@ -30,11 +32,15 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } -tokio = { workspace = true, features = ["fs"] } +tokio = { workspace = true, features = ["fs", "macros", "rt", "time"] } toml = { workspace = true } tracing = { workspace = true } url = { workspace = true } +zip = { workspace = true } [dev-dependencies] +anyhow = { workspace = true } +libc = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } +wiremock = { workspace = true } diff --git a/codex-rs/core/src/plugins/installed_marketplaces.rs b/codex-rs/core-plugins/src/installed_marketplaces.rs similarity index 86% rename from codex-rs/core/src/plugins/installed_marketplaces.rs rename to codex-rs/core-plugins/src/installed_marketplaces.rs index a6f80139a9..f313a06276 100644 --- a/codex-rs/core/src/plugins/installed_marketplaces.rs +++ b/codex-rs/core-plugins/src/installed_marketplaces.rs @@ -1,11 +1,12 @@ -use crate::config::Config; -use codex_core_plugins::marketplace::find_marketplace_manifest_path; -use codex_utils_absolute_path::AbsolutePathBuf; use std::path::Path; use std::path::PathBuf; + +use codex_config::ConfigLayerStack; +use codex_plugin::validate_plugin_segment; +use codex_utils_absolute_path::AbsolutePathBuf; use tracing::warn; -use super::validate_plugin_segment; +use crate::marketplace::find_marketplace_manifest_path; pub const INSTALLED_MARKETPLACES_DIR: &str = ".tmp/marketplaces"; @@ -13,11 +14,11 @@ pub fn marketplace_install_root(codex_home: &Path) -> PathBuf { codex_home.join(INSTALLED_MARKETPLACES_DIR) } -pub(crate) fn installed_marketplace_roots_from_config( - config: &Config, +pub fn installed_marketplace_roots_from_layer_stack( + config_layer_stack: &ConfigLayerStack, codex_home: &Path, ) -> Vec { - let Some(user_layer) = config.config_layer_stack.get_user_layer() else { + let Some(user_layer) = config_layer_stack.get_user_layer() else { return Vec::new(); }; let Some(marketplaces_value) = user_layer.config.get("marketplaces") else { @@ -59,7 +60,7 @@ pub(crate) fn installed_marketplace_roots_from_config( roots } -pub(crate) fn resolve_configured_marketplace_root( +pub fn resolve_configured_marketplace_root( marketplace_name: &str, marketplace: &toml::Value, default_install_root: &Path, diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 82ff4df3c7..61e5ef3724 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -1,7 +1,15 @@ +pub mod installed_marketplaces; pub mod loader; pub mod manifest; pub mod marketplace; +pub mod marketplace_add; +pub mod marketplace_remove; pub mod marketplace_upgrade; pub mod remote; +pub mod remote_legacy; +pub mod startup_sync; pub mod store; pub mod toggles; + +pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated"; +pub const OPENAI_BUNDLED_MARKETPLACE_NAME: &str = "openai-bundled"; diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 21c5aa6b43..589467199e 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -1,3 +1,4 @@ +use crate::OPENAI_CURATED_MARKETPLACE_NAME; use crate::manifest::PluginManifestPaths; use crate::manifest::load_plugin_manifest; use crate::marketplace::MarketplacePluginSource; @@ -40,8 +41,8 @@ use tracing::warn; const DEFAULT_SKILLS_DIR_NAME: &str = "skills"; const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json"; const DEFAULT_APP_CONFIG_FILE: &str = ".app.json"; -const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated"; const CONFIG_TOML_FILE: &str = "config.toml"; +const CURATED_PLUGIN_CACHE_VERSION_SHA_PREFIX_LEN: usize = 8; #[derive(Clone, Copy, PartialEq, Eq)] enum NonCuratedCacheRefreshMode { @@ -144,6 +145,7 @@ pub fn refresh_curated_plugin_cache( plugin_version: &str, configured_curated_plugin_ids: &[PluginId], ) -> Result { + let cache_plugin_version = curated_plugin_cache_version(plugin_version); let store = PluginStore::try_new(codex_home.to_path_buf()).map_err(|err| err.to_string())?; let curated_marketplace_path = AbsolutePathBuf::try_from( codex_home @@ -181,7 +183,8 @@ pub fn refresh_curated_plugin_cache( let mut cache_refreshed = false; for plugin_id in configured_curated_plugin_ids { - if store.active_plugin_version(plugin_id).as_deref() == Some(plugin_version) { + if store.active_plugin_version(plugin_id).as_deref() == Some(cache_plugin_version.as_str()) + { continue; } @@ -195,7 +198,7 @@ pub fn refresh_curated_plugin_cache( }; store - .install_with_version(source_path, plugin_id.clone(), plugin_version.to_string()) + .install_with_version(source_path, plugin_id.clone(), cache_plugin_version.clone()) .map_err(|err| { format!( "failed to refresh curated plugin cache for {}: {err}", @@ -208,6 +211,14 @@ pub fn refresh_curated_plugin_cache( Ok(cache_refreshed) } +pub fn curated_plugin_cache_version(plugin_version: &str) -> String { + if is_full_git_sha(plugin_version) { + plugin_version[..CURATED_PLUGIN_CACHE_VERSION_SHA_PREFIX_LEN].to_string() + } else { + plugin_version.to_string() + } +} + pub fn refresh_non_curated_plugin_cache( codex_home: &Path, additional_roots: &[AbsolutePathBuf], @@ -328,6 +339,10 @@ fn configured_plugins_from_stack( configured_plugins_from_user_config_value(&user_layer.config) } +fn is_full_git_sha(value: &str) -> bool { + value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit()) +} + fn configured_plugins_from_user_config_value( user_config: &toml::Value, ) -> HashMap { @@ -1079,6 +1094,23 @@ mod tests { ); } + #[test] + fn curated_plugin_cache_version_shortens_full_git_sha() { + assert_eq!( + curated_plugin_cache_version("0123456789abcdef0123456789abcdef01234567"), + "01234567" + ); + } + + #[test] + fn curated_plugin_cache_version_preserves_non_git_sha_versions() { + assert_eq!( + curated_plugin_cache_version("export-backup"), + "export-backup" + ); + assert_eq!(curated_plugin_cache_version("0123456"), "0123456"); + } + #[test] fn materialize_git_subdir_uses_sparse_checkout() { let codex_home = tempfile::tempdir().expect("create codex home"); diff --git a/codex-rs/core/src/plugins/marketplace_add.rs b/codex-rs/core-plugins/src/marketplace_add.rs similarity index 97% rename from codex-rs/core/src/plugins/marketplace_add.rs rename to codex-rs/core-plugins/src/marketplace_add.rs index a03a2134e5..57e587e480 100644 --- a/codex-rs/core/src/plugins/marketplace_add.rs +++ b/codex-rs/core-plugins/src/marketplace_add.rs @@ -1,5 +1,5 @@ -use super::OPENAI_CURATED_MARKETPLACE_NAME; -use super::marketplace_install_root; +use crate::OPENAI_CURATED_MARKETPLACE_NAME; +use crate::installed_marketplaces::marketplace_install_root; use codex_utils_absolute_path::AbsolutePathBuf; use std::fs; use std::path::Path; @@ -278,10 +278,9 @@ mod tests { let expected_source = source_root.path().canonicalize()?.display().to_string(); assert_eq!(result.marketplace_name, "debug"); assert_eq!(result.source_display, expected_source); - assert_eq!( - result.installed_root.as_path(), - source_root.path().canonicalize()? - ); + let expected_installed_root = + AbsolutePathBuf::from_absolute_path(source_root.path().canonicalize()?)?; + assert_eq!(result.installed_root, expected_installed_root); assert!(!result.already_added); assert!( !marketplace_install_root(codex_home.path()) diff --git a/codex-rs/core/src/plugins/marketplace_add/install.rs b/codex-rs/core-plugins/src/marketplace_add/install.rs similarity index 100% rename from codex-rs/core/src/plugins/marketplace_add/install.rs rename to codex-rs/core-plugins/src/marketplace_add/install.rs diff --git a/codex-rs/core/src/plugins/marketplace_add/metadata.rs b/codex-rs/core-plugins/src/marketplace_add/metadata.rs similarity index 98% rename from codex-rs/core/src/plugins/marketplace_add/metadata.rs rename to codex-rs/core-plugins/src/marketplace_add/metadata.rs index 06b5e39564..66b17f6c40 100644 --- a/codex-rs/core/src/plugins/marketplace_add/metadata.rs +++ b/codex-rs/core-plugins/src/marketplace_add/metadata.rs @@ -1,10 +1,10 @@ use super::MarketplaceAddError; use super::source::MarketplaceSource; -use crate::plugins::installed_marketplaces::resolve_configured_marketplace_root; +use crate::installed_marketplaces::resolve_configured_marketplace_root; +use crate::marketplace::validate_marketplace_root; use codex_config::CONFIG_TOML_FILE; use codex_config::MarketplaceConfigUpdate; use codex_config::record_user_marketplace; -use codex_core_plugins::marketplace::validate_marketplace_root; use std::fs; use std::io::ErrorKind; use std::path::Path; diff --git a/codex-rs/core/src/plugins/marketplace_add/source.rs b/codex-rs/core-plugins/src/marketplace_add/source.rs similarity index 99% rename from codex-rs/core/src/plugins/marketplace_add/source.rs rename to codex-rs/core-plugins/src/marketplace_add/source.rs index e723c8865a..fdcbd4094e 100644 --- a/codex-rs/core/src/plugins/marketplace_add/source.rs +++ b/codex-rs/core-plugins/src/marketplace_add/source.rs @@ -1,6 +1,6 @@ use super::MarketplaceAddError; -use crate::plugins::validate_plugin_segment; -use codex_core_plugins::marketplace::validate_marketplace_root; +use crate::marketplace::validate_marketplace_root; +use codex_plugin::validate_plugin_segment; use std::path::Path; use std::path::PathBuf; diff --git a/codex-rs/core/src/plugins/marketplace_remove.rs b/codex-rs/core-plugins/src/marketplace_remove.rs similarity index 99% rename from codex-rs/core/src/plugins/marketplace_remove.rs rename to codex-rs/core-plugins/src/marketplace_remove.rs index 490b16f95d..aa5a5078aa 100644 --- a/codex-rs/core/src/plugins/marketplace_remove.rs +++ b/codex-rs/core-plugins/src/marketplace_remove.rs @@ -1,7 +1,7 @@ -use crate::plugins::marketplace_install_root; -use crate::plugins::validate_plugin_segment; +use crate::installed_marketplaces::marketplace_install_root; use codex_config::RemoveMarketplaceConfigOutcome; use codex_config::remove_user_marketplace_config; +use codex_plugin::validate_plugin_segment; use codex_utils_absolute_path::AbsolutePathBuf; use std::fs; use std::path::Path; diff --git a/codex-rs/core-plugins/src/marketplace_upgrade/git.rs b/codex-rs/core-plugins/src/marketplace_upgrade/git.rs index 80a7c68f68..9a46465dbc 100644 --- a/codex-rs/core-plugins/src/marketplace_upgrade/git.rs +++ b/codex-rs/core-plugins/src/marketplace_upgrade/git.rs @@ -1,4 +1,5 @@ use std::path::Path; +use std::path::PathBuf; use std::process::Command; use std::process::Output; use std::process::Stdio; @@ -46,9 +47,10 @@ pub(super) fn clone_git_source( destination: &Path, timeout: Duration, ) -> Result { + let git_destination = git_path_arg(destination); if sparse_paths.is_empty() { let output = run_git_command_with_timeout( - git_command().arg("clone").arg(source).arg(destination), + git_command().arg("clone").arg(source).arg(&git_destination), "git clone marketplace source", timeout, )?; @@ -57,7 +59,7 @@ pub(super) fn clone_git_source( let output = run_git_command_with_timeout( git_command() .arg("-C") - .arg(destination) + .arg(&git_destination) .arg("checkout") .arg(ref_name), "git checkout marketplace ref", @@ -65,7 +67,7 @@ pub(super) fn clone_git_source( )?; ensure_git_success(&output, "git checkout marketplace ref")?; } - return git_worktree_revision(destination, timeout); + return git_worktree_revision(&git_destination, timeout); } let output = run_git_command_with_timeout( @@ -74,7 +76,7 @@ pub(super) fn clone_git_source( .arg("--filter=blob:none") .arg("--no-checkout") .arg(source) - .arg(destination), + .arg(&git_destination), "git clone marketplace source", timeout, )?; @@ -83,7 +85,7 @@ pub(super) fn clone_git_source( let mut sparse_checkout = git_command(); sparse_checkout .arg("-C") - .arg(destination) + .arg(&git_destination) .arg("sparse-checkout") .arg("set") .args(sparse_paths); @@ -97,14 +99,14 @@ pub(super) fn clone_git_source( let output = run_git_command_with_timeout( git_command() .arg("-C") - .arg(destination) + .arg(&git_destination) .arg("checkout") .arg(ref_name.unwrap_or("HEAD")), "git checkout marketplace ref", timeout, )?; ensure_git_success(&output, "git checkout marketplace ref")?; - git_worktree_revision(destination, timeout) + git_worktree_revision(&git_destination, timeout) } fn git_worktree_revision(destination: &Path, timeout: Duration) -> Result { @@ -139,6 +141,28 @@ fn git_command() -> Command { command } +#[cfg(windows)] +fn git_path_arg(path: &Path) -> PathBuf { + strip_windows_verbatim_path_prefix(&path.to_string_lossy()) + .map(PathBuf::from) + .unwrap_or_else(|| path.to_path_buf()) +} + +#[cfg(not(windows))] +fn git_path_arg(path: &Path) -> PathBuf { + path.to_path_buf() +} + +#[cfg(any(windows, test))] +fn strip_windows_verbatim_path_prefix(path: &str) -> Option { + let stripped = path.strip_prefix(r"\\?\")?; + let stripped = stripped + .strip_prefix(r"UNC\") + .map(|unc_path| format!(r"\\{unc_path}")) + .unwrap_or_else(|| stripped.to_string()); + Some(stripped) +} + fn run_git_command_with_timeout( command: &mut Command, context: &str, @@ -201,6 +225,8 @@ fn ensure_git_success(output: &Output, context: &str) -> Result<(), String> { mod tests { use super::git_command; use super::is_full_git_sha; + use super::strip_windows_verbatim_path_prefix; + use pretty_assertions::assert_eq; use std::ffi::OsStr; #[test] @@ -226,6 +252,27 @@ mod tests { assert_eq!(command_env(&command, "PATH"), None); } + #[test] + fn strips_windows_verbatim_disk_prefix_for_git() { + assert_eq!( + strip_windows_verbatim_path_prefix(r"\\?\C:\Users\alice\marketplace"), + Some(r"C:\Users\alice\marketplace".to_string()) + ); + } + + #[test] + fn strips_windows_verbatim_unc_prefix_for_git() { + assert_eq!( + strip_windows_verbatim_path_prefix(r"\\?\UNC\server\share\marketplace"), + Some(r"\\server\share\marketplace".to_string()) + ); + } + + #[test] + fn leaves_non_verbatim_path_without_rewrite() { + assert_eq!(strip_windows_verbatim_path_prefix(r"C:\Users\alice"), None); + } + fn command_env<'a>( command: &'a std::process::Command, name: &str, diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 34ece0880a..e453c52f97 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -1,82 +1,120 @@ +use codex_app_server_protocol::PluginAuthPolicy; +use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInterface; +use codex_app_server_protocol::SkillInterface; use codex_login::CodexAuth; use codex_login::default_client::build_reqwest_client; -use codex_protocol::protocol::Product; +use reqwest::RequestBuilder; use serde::Deserialize; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::collections::HashSet; use std::time::Duration; -use url::Url; -const DEFAULT_REMOTE_MARKETPLACE_NAME: &str = "openai-curated"; -const REMOTE_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(30); -const REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(10); -const REMOTE_PLUGIN_MUTATION_TIMEOUT: Duration = Duration::from_secs(30); +pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "chatgpt-global"; +pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "chatgpt-workspace"; +pub const REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Plugins"; +pub const REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Workspace Plugins"; + +const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30); +const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200; +const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemotePluginServiceConfig { pub chatgpt_base_url: String, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -pub struct RemotePluginStatusSummary { +#[derive(Debug, Clone, PartialEq)] +pub struct RemoteMarketplace { pub name: String, - #[serde(default = "default_remote_marketplace_name")] - pub marketplace_name: String, - pub enabled: bool, + pub display_name: String, + pub plugins: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "camelCase")] -struct RemotePluginMutationResponse { +#[derive(Debug, Clone, PartialEq)] +pub struct RemotePluginSummary { pub id: String, + pub name: String, + pub installed: bool, + pub enabled: bool, + pub install_policy: PluginInstallPolicy, + pub auth_policy: PluginAuthPolicy, + pub interface: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RemotePluginDetail { + pub marketplace_name: String, + pub marketplace_display_name: String, + pub summary: RemotePluginSummary, + pub description: Option, + pub skills: Vec, + pub app_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RemotePluginSkill { + pub name: String, + pub description: String, + pub short_description: Option, + pub interface: Option, pub enabled: bool, } #[derive(Debug, thiserror::Error)] -pub enum RemotePluginMutationError { - #[error("chatgpt authentication required for remote plugin mutation")] +pub enum RemotePluginCatalogError { + #[error("chatgpt authentication required for remote plugin catalog")] AuthRequired, #[error( - "chatgpt authentication required for remote plugin mutation; api key auth is not supported" + "chatgpt authentication required for remote plugin catalog; api key auth is not supported" )] UnsupportedAuthMode, - #[error("failed to read auth token for remote plugin mutation: {0}")] + #[error("failed to read auth token for remote plugin catalog: {0}")] AuthToken(#[source] std::io::Error), - #[error("invalid chatgpt base url for remote plugin mutation: {0}")] - InvalidBaseUrl(#[source] url::ParseError), - - #[error("chatgpt base url cannot be used for plugin mutation")] - InvalidBaseUrlPath, - - #[error("failed to send remote plugin mutation request to {url}: {source}")] + #[error("failed to send remote plugin catalog request to {url}: {source}")] Request { url: String, #[source] source: reqwest::Error, }, - #[error("remote plugin mutation failed with status {status} from {url}: {body}")] + #[error("remote plugin catalog request to {url} failed with status {status}: {body}")] UnexpectedStatus { url: String, status: reqwest::StatusCode, body: String, }, - #[error("failed to parse remote plugin mutation response from {url}: {source}")] + #[error("failed to parse remote plugin catalog response from {url}: {source}")] Decode { url: String, #[source] source: serde_json::Error, }, + #[error("remote marketplace `{marketplace_name}` is not supported")] + UnknownMarketplace { marketplace_name: String }, + #[error( - "remote plugin mutation returned unexpected plugin id: expected `{expected}`, got `{actual}`" + "remote plugin `{plugin_id}` belongs to marketplace `{actual_marketplace_name}`, not `{expected_marketplace_name}`" + )] + MarketplaceMismatch { + plugin_id: String, + expected_marketplace_name: String, + actual_marketplace_name: String, + }, + + #[error( + "remote plugin install returned unexpected plugin id: expected `{expected}`, got `{actual}`" )] UnexpectedPluginId { expected: String, actual: String }, #[error( - "remote plugin mutation returned unexpected enabled state for `{plugin_id}`: expected {expected_enabled}, got {actual_enabled}" + "remote plugin install returned unexpected enabled state for `{plugin_id}`: expected {expected_enabled}, got {actual_enabled}" )] UnexpectedEnabledState { plugin_id: String, @@ -85,233 +123,584 @@ pub enum RemotePluginMutationError { }, } -#[derive(Debug, thiserror::Error)] -pub enum RemotePluginFetchError { - #[error("chatgpt authentication required to sync remote plugins")] - AuthRequired, - - #[error( - "chatgpt authentication required to sync remote plugins; api key auth is not supported" - )] - UnsupportedAuthMode, - - #[error("failed to read auth token for remote plugin sync: {0}")] - AuthToken(#[source] std::io::Error), - - #[error("failed to send remote plugin sync request to {url}: {source}")] - Request { - url: String, - #[source] - source: reqwest::Error, - }, - - #[error("remote plugin sync request to {url} failed with status {status}: {body}")] - UnexpectedStatus { - url: String, - status: reqwest::StatusCode, - body: String, - }, - - #[error("failed to parse remote plugin sync response from {url}: {source}")] - Decode { - url: String, - #[source] - source: serde_json::Error, - }, +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)] +enum RemotePluginScope { + #[serde(rename = "GLOBAL")] + Global, + #[serde(rename = "WORKSPACE")] + Workspace, } -pub async fn fetch_remote_plugin_status( - config: &RemotePluginServiceConfig, - auth: Option<&CodexAuth>, -) -> Result, RemotePluginFetchError> { - let Some(auth) = auth else { - return Err(RemotePluginFetchError::AuthRequired); - }; - if !auth.is_chatgpt_auth() { - return Err(RemotePluginFetchError::UnsupportedAuthMode); +impl RemotePluginScope { + fn all() -> [Self; 2] { + [Self::Global, Self::Workspace] } - let base_url = config.chatgpt_base_url.trim_end_matches('/'); - let url = format!("{base_url}/plugins/list"); - let client = build_reqwest_client(); - let token = auth - .get_token() - .map_err(RemotePluginFetchError::AuthToken)?; - let mut request = client - .get(&url) - .timeout(REMOTE_PLUGIN_FETCH_TIMEOUT) - .bearer_auth(token); - if let Some(account_id) = auth.get_account_id() { - request = request.header("chatgpt-account-id", account_id); - } - - let response = request - .send() - .await - .map_err(|source| RemotePluginFetchError::Request { - url: url.clone(), - source, - })?; - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - if !status.is_success() { - return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body }); - } - - serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode { - url: url.clone(), - source, - }) -} - -pub async fn fetch_remote_featured_plugin_ids( - config: &RemotePluginServiceConfig, - auth: Option<&CodexAuth>, - product: Option, -) -> Result, RemotePluginFetchError> { - let base_url = config.chatgpt_base_url.trim_end_matches('/'); - let url = format!("{base_url}/plugins/featured"); - let client = build_reqwest_client(); - let mut request = client - .get(&url) - .query(&[( - "platform", - product.unwrap_or(Product::Codex).to_app_platform(), - )]) - .timeout(REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT); - - if let Some(auth) = auth.filter(|auth| auth.is_chatgpt_auth()) { - let token = auth - .get_token() - .map_err(RemotePluginFetchError::AuthToken)?; - request = request.bearer_auth(token); - if let Some(account_id) = auth.get_account_id() { - request = request.header("chatgpt-account-id", account_id); + fn api_value(self) -> &'static str { + match self { + Self::Global => "GLOBAL", + Self::Workspace => "WORKSPACE", } } - let response = request - .send() - .await - .map_err(|source| RemotePluginFetchError::Request { - url: url.clone(), - source, - })?; - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - if !status.is_success() { - return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body }); + fn marketplace_name(self) -> &'static str { + match self { + Self::Global => REMOTE_GLOBAL_MARKETPLACE_NAME, + Self::Workspace => REMOTE_WORKSPACE_MARKETPLACE_NAME, + } } - serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode { - url: url.clone(), - source, + fn marketplace_display_name(self) -> &'static str { + match self { + Self::Global => REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME, + Self::Workspace => REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME, + } + } + + fn from_marketplace_name(name: &str) -> Option { + match name { + REMOTE_GLOBAL_MARKETPLACE_NAME => Some(Self::Global), + REMOTE_WORKSPACE_MARKETPLACE_NAME => Some(Self::Workspace), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginPagination { + #[serde(alias = "nextPageToken")] + next_page_token: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginSkillInterfaceResponse { + #[serde(alias = "displayName")] + display_name: Option, + #[serde(alias = "shortDescription")] + short_description: Option, + #[serde(alias = "brandColor")] + brand_color: Option, + #[serde(alias = "defaultPrompt")] + default_prompt: Option, + #[serde(alias = "iconSmallUrl")] + icon_small_url: Option, + #[serde(alias = "iconLargeUrl")] + icon_large_url: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginSkillResponse { + name: String, + description: String, + interface: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginReleaseInterfaceResponse { + #[serde(alias = "shortDescription")] + short_description: Option, + #[serde(alias = "longDescription")] + long_description: Option, + #[serde(alias = "developerName")] + developer_name: Option, + category: Option, + #[serde(default)] + capabilities: Vec, + #[serde(alias = "websiteUrl")] + website_url: Option, + #[serde(alias = "privacyPolicyUrl")] + privacy_policy_url: Option, + #[serde(alias = "termsOfServiceUrl")] + terms_of_service_url: Option, + #[serde(alias = "brandColor")] + brand_color: Option, + #[serde(alias = "defaultPrompt")] + default_prompt: Option, + #[serde(alias = "composerIconUrl")] + composer_icon_url: Option, + #[serde(alias = "logoUrl")] + logo_url: Option, + #[serde(default)] + #[serde(alias = "screenshotUrls")] + screenshot_urls: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginReleaseResponse { + #[serde(alias = "displayName")] + display_name: String, + description: String, + #[serde(default)] + #[serde(alias = "appIds")] + app_ids: Vec, + interface: RemotePluginReleaseInterfaceResponse, + #[serde(default)] + skills: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginDirectoryItem { + id: String, + name: String, + scope: RemotePluginScope, + #[serde(alias = "installationPolicy")] + installation_policy: PluginInstallPolicy, + #[serde(alias = "authenticationPolicy")] + authentication_policy: PluginAuthPolicy, + release: RemotePluginReleaseResponse, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginInstalledItem { + #[serde(flatten)] + plugin: RemotePluginDirectoryItem, + enabled: bool, + #[serde(default)] + #[serde(alias = "disabledSkillNames")] + disabled_skill_names: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginListResponse { + plugins: Vec, + pagination: RemotePluginPagination, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginInstalledResponse { + plugins: Vec, + pagination: RemotePluginPagination, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginInstallResponse { + id: String, + enabled: bool, +} + +pub async fn fetch_remote_marketplaces( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, +) -> Result, RemotePluginCatalogError> { + let auth = ensure_chatgpt_auth(auth)?; + let mut directory_by_scope = + BTreeMap::>::new(); + let mut installed_by_scope = + BTreeMap::>::new(); + + let global = async { + let scope = RemotePluginScope::Global; + let (directory_plugins, installed_plugins) = tokio::try_join!( + fetch_directory_plugins_for_scope(config, auth, scope), + fetch_installed_plugins_for_scope(config, auth, scope), + )?; + Ok::<_, RemotePluginCatalogError>((scope, directory_plugins, installed_plugins)) + }; + let workspace = async { + let scope = RemotePluginScope::Workspace; + let (directory_plugins, installed_plugins) = tokio::try_join!( + fetch_directory_plugins_for_scope(config, auth, scope), + fetch_installed_plugins_for_scope(config, auth, scope), + )?; + Ok::<_, RemotePluginCatalogError>((scope, directory_plugins, installed_plugins)) + }; + + let (global, workspace) = tokio::try_join!(global, workspace)?; + for (scope, directory_plugins, installed_plugins) in [global, workspace] { + if !directory_plugins.is_empty() { + directory_by_scope.insert( + scope, + directory_plugins + .into_iter() + .map(|plugin| (plugin.id.clone(), plugin)) + .collect(), + ); + } + if !installed_plugins.is_empty() { + installed_by_scope.insert( + scope, + installed_plugins + .into_iter() + .map(|plugin| (plugin.plugin.id.clone(), plugin)) + .collect(), + ); + } + } + + let mut marketplaces = Vec::new(); + for scope in RemotePluginScope::all() { + let directory_plugins = directory_by_scope.get(&scope); + let installed_plugins = installed_by_scope.get(&scope); + let plugin_ids = directory_plugins + .into_iter() + .flat_map(|plugins| plugins.keys()) + .chain( + installed_plugins + .into_iter() + .flat_map(|plugins| plugins.keys()), + ) + .cloned() + .collect::>(); + if plugin_ids.is_empty() { + continue; + } + + let mut plugins = plugin_ids + .into_iter() + .filter_map(|plugin_id| { + let directory_plugin = + directory_plugins.and_then(|plugins| plugins.get(&plugin_id)); + let installed_plugin = + installed_plugins.and_then(|plugins| plugins.get(&plugin_id)); + directory_plugin + .or_else(|| installed_plugin.map(|plugin| &plugin.plugin)) + .map(|plugin| build_remote_plugin_summary(plugin, installed_plugin)) + }) + .collect::>(); + plugins.sort_by(|left, right| { + remote_plugin_display_name(left) + .to_ascii_lowercase() + .cmp(&remote_plugin_display_name(right).to_ascii_lowercase()) + .then_with(|| { + remote_plugin_display_name(left).cmp(remote_plugin_display_name(right)) + }) + .then_with(|| left.id.cmp(&right.id)) + }); + marketplaces.push(RemoteMarketplace { + name: scope.marketplace_name().to_string(), + display_name: scope.marketplace_display_name().to_string(), + plugins, + }); + } + + Ok(marketplaces) +} + +pub async fn fetch_remote_plugin_detail( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + marketplace_name: &str, + plugin_id: &str, +) -> Result { + let auth = ensure_chatgpt_auth(auth)?; + let scope = RemotePluginScope::from_marketplace_name(marketplace_name).ok_or_else(|| { + RemotePluginCatalogError::UnknownMarketplace { + marketplace_name: marketplace_name.to_string(), + } + })?; + let plugin = fetch_plugin_detail(config, auth, plugin_id).await?; + let actual_marketplace_name = plugin.scope.marketplace_name(); + if actual_marketplace_name != marketplace_name { + return Err(RemotePluginCatalogError::MarketplaceMismatch { + plugin_id: plugin_id.to_string(), + expected_marketplace_name: marketplace_name.to_string(), + actual_marketplace_name: actual_marketplace_name.to_string(), + }); + } + + let installed_plugin = fetch_installed_plugins_for_scope(config, auth, scope) + .await? + .into_iter() + .find(|installed_plugin| installed_plugin.plugin.id == plugin_id); + let disabled_skill_names = installed_plugin + .as_ref() + .map(|plugin| { + plugin + .disabled_skill_names + .iter() + .cloned() + .collect::>() + }) + .unwrap_or_default(); + let skills = plugin + .release + .skills + .iter() + .map(|skill| RemotePluginSkill { + name: skill.name.clone(), + description: skill.description.clone(), + short_description: skill + .interface + .as_ref() + .and_then(|interface| interface.short_description.clone()), + interface: remote_skill_interface_to_info(skill.interface.clone()), + enabled: !disabled_skill_names.contains(&skill.name), + }) + .collect(); + + Ok(RemotePluginDetail { + marketplace_name: marketplace_name.to_string(), + marketplace_display_name: scope.marketplace_display_name().to_string(), + summary: build_remote_plugin_summary(&plugin, installed_plugin.as_ref()), + description: non_empty_string(Some(&plugin.release.description)), + skills, + app_ids: plugin.release.app_ids, }) } -pub async fn enable_remote_plugin( +pub async fn install_remote_plugin( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, + marketplace_name: &str, plugin_id: &str, -) -> Result<(), RemotePluginMutationError> { - post_remote_plugin_mutation(config, auth, plugin_id, "enable").await?; +) -> Result<(), RemotePluginCatalogError> { + let auth = ensure_chatgpt_auth(auth)?; + if RemotePluginScope::from_marketplace_name(marketplace_name).is_none() { + return Err(RemotePluginCatalogError::UnknownMarketplace { + marketplace_name: marketplace_name.to_string(), + }); + } + + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/ps/plugins/{plugin_id}/install"); + let client = build_reqwest_client(); + let request = authenticated_request(client.post(&url), auth)?; + let response: RemotePluginInstallResponse = send_and_decode(request, &url).await?; + if response.id != plugin_id { + return Err(RemotePluginCatalogError::UnexpectedPluginId { + expected: plugin_id.to_string(), + actual: response.id, + }); + } + if !response.enabled { + return Err(RemotePluginCatalogError::UnexpectedEnabledState { + plugin_id: plugin_id.to_string(), + expected_enabled: true, + actual_enabled: response.enabled, + }); + } + Ok(()) } -pub async fn uninstall_remote_plugin( - config: &RemotePluginServiceConfig, - auth: Option<&CodexAuth>, - plugin_id: &str, -) -> Result<(), RemotePluginMutationError> { - post_remote_plugin_mutation(config, auth, plugin_id, "uninstall").await?; - Ok(()) +fn build_remote_plugin_summary( + plugin: &RemotePluginDirectoryItem, + installed_plugin: Option<&RemotePluginInstalledItem>, +) -> RemotePluginSummary { + RemotePluginSummary { + id: plugin.id.clone(), + name: plugin.name.clone(), + installed: installed_plugin.is_some(), + enabled: installed_plugin.is_some_and(|plugin| plugin.enabled), + install_policy: plugin.installation_policy, + auth_policy: plugin.authentication_policy, + interface: remote_plugin_interface_to_info(plugin), + } } -fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginMutationError> { - let Some(auth) = auth else { - return Err(RemotePluginMutationError::AuthRequired); +fn remote_plugin_interface_to_info(plugin: &RemotePluginDirectoryItem) -> Option { + let interface = &plugin.release.interface; + let display_name = non_empty_string(Some(&plugin.release.display_name)); + let default_prompt = interface + .default_prompt + .as_ref() + .and_then(|prompt| normalize_remote_default_prompt(prompt)); + let result = PluginInterface { + display_name, + short_description: interface.short_description.clone(), + long_description: interface.long_description.clone(), + developer_name: interface.developer_name.clone(), + category: interface.category.clone(), + capabilities: interface.capabilities.clone(), + website_url: interface.website_url.clone(), + privacy_policy_url: interface.privacy_policy_url.clone(), + terms_of_service_url: interface.terms_of_service_url.clone(), + default_prompt, + brand_color: interface.brand_color.clone(), + composer_icon: None, + composer_icon_url: interface.composer_icon_url.clone(), + logo: None, + logo_url: interface.logo_url.clone(), + screenshots: Vec::new(), + screenshot_urls: interface.screenshot_urls.clone(), }; - if !auth.is_chatgpt_auth() { - return Err(RemotePluginMutationError::UnsupportedAuthMode); + let has_fields = result.display_name.is_some() + || result.short_description.is_some() + || result.long_description.is_some() + || result.developer_name.is_some() + || result.category.is_some() + || !result.capabilities.is_empty() + || result.website_url.is_some() + || result.privacy_policy_url.is_some() + || result.terms_of_service_url.is_some() + || result.default_prompt.is_some() + || result.brand_color.is_some() + || result.composer_icon_url.is_some() + || result.logo_url.is_some() + || !result.screenshot_urls.is_empty(); + has_fields.then_some(result) +} + +fn remote_skill_interface_to_info( + interface: Option, +) -> Option { + interface.and_then(|interface| { + let result = SkillInterface { + display_name: interface.display_name, + short_description: interface.short_description, + icon_small: None, + icon_large: None, + brand_color: interface.brand_color, + default_prompt: interface.default_prompt, + }; + let has_fields = result.display_name.is_some() + || result.short_description.is_some() + || result.brand_color.is_some() + || result.default_prompt.is_some(); + has_fields.then_some(result) + }) +} + +fn remote_plugin_display_name(plugin: &RemotePluginSummary) -> &str { + plugin + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()) + .unwrap_or(&plugin.name) +} + +fn non_empty_string(value: Option<&str>) -> Option { + value.and_then(|value| { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) + }) +} + +fn normalize_remote_default_prompt(prompt: &str) -> Option> { + let prompt = prompt.trim(); + if prompt.is_empty() || prompt.chars().count() > MAX_REMOTE_DEFAULT_PROMPT_LEN { + return None; + } + Some(vec![prompt.to_string()]) +} + +async fn fetch_directory_plugins_for_scope( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + scope: RemotePluginScope, +) -> Result, RemotePluginCatalogError> { + let mut plugins = Vec::new(); + let mut page_token = None; + loop { + let response = + get_remote_plugin_list_page(config, auth, scope, page_token.as_deref()).await?; + plugins.extend(response.plugins); + let Some(next_page_token) = response.pagination.next_page_token else { + break; + }; + page_token = Some(next_page_token); + } + Ok(plugins) +} + +async fn fetch_installed_plugins_for_scope( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + scope: RemotePluginScope, +) -> Result, RemotePluginCatalogError> { + let mut plugins = Vec::new(); + let mut page_token = None; + loop { + let response = + get_remote_plugin_installed_page(config, auth, scope, page_token.as_deref()).await?; + plugins.extend(response.plugins); + let Some(next_page_token) = response.pagination.next_page_token else { + break; + }; + page_token = Some(next_page_token); + } + Ok(plugins) +} + +async fn get_remote_plugin_list_page( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + scope: RemotePluginScope, + page_token: Option<&str>, +) -> Result { + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/ps/plugins/list"); + let client = build_reqwest_client(); + let mut request = authenticated_request(client.get(&url), auth)?; + request = request.query(&[("scope", scope.api_value())]); + request = request.query(&[("limit", REMOTE_PLUGIN_LIST_PAGE_LIMIT)]); + if let Some(page_token) = page_token { + request = request.query(&[("pageToken", page_token)]); + } + send_and_decode(request, &url).await +} + +async fn get_remote_plugin_installed_page( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + scope: RemotePluginScope, + page_token: Option<&str>, +) -> Result { + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/ps/plugins/installed"); + let client = build_reqwest_client(); + let mut request = authenticated_request(client.get(&url), auth)?; + request = request.query(&[("scope", scope.api_value())]); + if let Some(page_token) = page_token { + request = request.query(&[("pageToken", page_token)]); + } + send_and_decode(request, &url).await +} + +async fn fetch_plugin_detail( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + plugin_id: &str, +) -> Result { + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/ps/plugins/{plugin_id}"); + let client = build_reqwest_client(); + let request = authenticated_request(client.get(&url), auth)?; + send_and_decode(request, &url).await +} + +fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginCatalogError> { + let Some(auth) = auth else { + return Err(RemotePluginCatalogError::AuthRequired); + }; + if !auth.uses_codex_backend() { + return Err(RemotePluginCatalogError::UnsupportedAuthMode); } Ok(auth) } -fn default_remote_marketplace_name() -> String { - DEFAULT_REMOTE_MARKETPLACE_NAME.to_string() +fn authenticated_request( + request: RequestBuilder, + auth: &CodexAuth, +) -> Result { + Ok(request + .timeout(REMOTE_PLUGIN_CATALOG_TIMEOUT) + .headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers())) } -async fn post_remote_plugin_mutation( - config: &RemotePluginServiceConfig, - auth: Option<&CodexAuth>, - plugin_id: &str, - action: &str, -) -> Result { - let auth = ensure_chatgpt_auth(auth)?; - let url = remote_plugin_mutation_url(config, plugin_id, action)?; - let client = build_reqwest_client(); - let token = auth - .get_token() - .map_err(RemotePluginMutationError::AuthToken)?; - let mut request = client - .post(url.clone()) - .timeout(REMOTE_PLUGIN_MUTATION_TIMEOUT) - .bearer_auth(token); - if let Some(account_id) = auth.get_account_id() { - request = request.header("chatgpt-account-id", account_id); - } - +async fn send_and_decode Deserialize<'de>>( + request: RequestBuilder, + url: &str, +) -> Result { let response = request .send() .await - .map_err(|source| RemotePluginMutationError::Request { - url: url.clone(), + .map_err(|source| RemotePluginCatalogError::Request { + url: url.to_string(), source, })?; let status = response.status(); let body = response.text().await.unwrap_or_default(); if !status.is_success() { - return Err(RemotePluginMutationError::UnexpectedStatus { url, status, body }); - } - - let parsed: RemotePluginMutationResponse = - serde_json::from_str(&body).map_err(|source| RemotePluginMutationError::Decode { - url: url.clone(), - source, - })?; - let expected_enabled = action == "enable"; - if parsed.id != plugin_id { - return Err(RemotePluginMutationError::UnexpectedPluginId { - expected: plugin_id.to_string(), - actual: parsed.id, - }); - } - if parsed.enabled != expected_enabled { - return Err(RemotePluginMutationError::UnexpectedEnabledState { - plugin_id: plugin_id.to_string(), - expected_enabled, - actual_enabled: parsed.enabled, + return Err(RemotePluginCatalogError::UnexpectedStatus { + url: url.to_string(), + status, + body, }); } - Ok(parsed) -} - -fn remote_plugin_mutation_url( - config: &RemotePluginServiceConfig, - plugin_id: &str, - action: &str, -) -> Result { - let mut url = Url::parse(config.chatgpt_base_url.trim_end_matches('/')) - .map_err(RemotePluginMutationError::InvalidBaseUrl)?; - { - let mut segments = url - .path_segments_mut() - .map_err(|()| RemotePluginMutationError::InvalidBaseUrlPath)?; - segments.pop_if_empty(); - segments.push("plugins"); - segments.push(plugin_id); - segments.push(action); - } - Ok(url.to_string()) + serde_json::from_str(&body).map_err(|source| RemotePluginCatalogError::Decode { + url: url.to_string(), + source, + }) } diff --git a/codex-rs/core-plugins/src/remote_legacy.rs b/codex-rs/core-plugins/src/remote_legacy.rs new file mode 100644 index 0000000000..dcf9f79eb8 --- /dev/null +++ b/codex-rs/core-plugins/src/remote_legacy.rs @@ -0,0 +1,298 @@ +use crate::remote::RemotePluginServiceConfig; +use codex_login::CodexAuth; +use codex_login::default_client::build_reqwest_client; +use codex_protocol::protocol::Product; +use serde::Deserialize; +use std::time::Duration; +use url::Url; + +const DEFAULT_REMOTE_MARKETPLACE_NAME: &str = "openai-curated"; +const REMOTE_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(30); +const REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(10); +const REMOTE_PLUGIN_MUTATION_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct RemotePluginStatusSummary { + pub name: String, + #[serde(default = "default_remote_marketplace_name")] + pub marketplace_name: String, + pub enabled: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RemotePluginMutationResponse { + pub id: String, + pub enabled: bool, +} + +#[derive(Debug, thiserror::Error)] +pub enum RemotePluginMutationError { + #[error("chatgpt authentication required for remote plugin mutation")] + AuthRequired, + + #[error( + "chatgpt authentication required for remote plugin mutation; api key auth is not supported" + )] + UnsupportedAuthMode, + + #[error("failed to read auth token for remote plugin mutation: {0}")] + AuthToken(#[source] std::io::Error), + + #[error("invalid chatgpt base url for remote plugin mutation: {0}")] + InvalidBaseUrl(#[source] url::ParseError), + + #[error("chatgpt base url cannot be used for plugin mutation")] + InvalidBaseUrlPath, + + #[error("failed to send remote plugin mutation request to {url}: {source}")] + Request { + url: String, + #[source] + source: reqwest::Error, + }, + + #[error("remote plugin mutation failed with status {status} from {url}: {body}")] + UnexpectedStatus { + url: String, + status: reqwest::StatusCode, + body: String, + }, + + #[error("failed to parse remote plugin mutation response from {url}: {source}")] + Decode { + url: String, + #[source] + source: serde_json::Error, + }, + + #[error( + "remote plugin mutation returned unexpected plugin id: expected `{expected}`, got `{actual}`" + )] + UnexpectedPluginId { expected: String, actual: String }, + + #[error( + "remote plugin mutation returned unexpected enabled state for `{plugin_id}`: expected {expected_enabled}, got {actual_enabled}" + )] + UnexpectedEnabledState { + plugin_id: String, + expected_enabled: bool, + actual_enabled: bool, + }, +} + +#[derive(Debug, thiserror::Error)] +pub enum RemotePluginFetchError { + #[error("chatgpt authentication required to sync remote plugins")] + AuthRequired, + + #[error( + "chatgpt authentication required to sync remote plugins; api key auth is not supported" + )] + UnsupportedAuthMode, + + #[error("failed to read auth token for remote plugin sync: {0}")] + AuthToken(#[source] std::io::Error), + + #[error("failed to send remote plugin sync request to {url}: {source}")] + Request { + url: String, + #[source] + source: reqwest::Error, + }, + + #[error("remote plugin sync request to {url} failed with status {status}: {body}")] + UnexpectedStatus { + url: String, + status: reqwest::StatusCode, + body: String, + }, + + #[error("failed to parse remote plugin sync response from {url}: {source}")] + Decode { + url: String, + #[source] + source: serde_json::Error, + }, +} + +pub async fn fetch_remote_plugin_status( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, +) -> Result, RemotePluginFetchError> { + let Some(auth) = auth else { + return Err(RemotePluginFetchError::AuthRequired); + }; + if !auth.uses_codex_backend() { + return Err(RemotePluginFetchError::UnsupportedAuthMode); + } + + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/plugins/list"); + let client = build_reqwest_client(); + let request = client + .get(&url) + .timeout(REMOTE_PLUGIN_FETCH_TIMEOUT) + .headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers()); + + let response = request + .send() + .await + .map_err(|source| RemotePluginFetchError::Request { + url: url.clone(), + source, + })?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body }); + } + + serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode { + url: url.clone(), + source, + }) +} + +pub async fn fetch_remote_featured_plugin_ids( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + product: Option, +) -> Result, RemotePluginFetchError> { + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/plugins/featured"); + let client = build_reqwest_client(); + let mut request = client + .get(&url) + .query(&[( + "platform", + product.unwrap_or(Product::Codex).to_app_platform(), + )]) + .timeout(REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT); + + if let Some(auth) = auth.filter(|auth| auth.uses_codex_backend()) { + request = + request.headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers()); + } + + let response = request + .send() + .await + .map_err(|source| RemotePluginFetchError::Request { + url: url.clone(), + source, + })?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body }); + } + + serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode { + url: url.clone(), + source, + }) +} + +pub async fn enable_remote_plugin( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + plugin_id: &str, +) -> Result<(), RemotePluginMutationError> { + post_remote_plugin_mutation(config, auth, plugin_id, "enable").await?; + Ok(()) +} + +pub async fn uninstall_remote_plugin( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + plugin_id: &str, +) -> Result<(), RemotePluginMutationError> { + post_remote_plugin_mutation(config, auth, plugin_id, "uninstall").await?; + Ok(()) +} + +fn ensure_codex_backend_auth( + auth: Option<&CodexAuth>, +) -> Result<&CodexAuth, RemotePluginMutationError> { + let Some(auth) = auth else { + return Err(RemotePluginMutationError::AuthRequired); + }; + if !auth.uses_codex_backend() { + return Err(RemotePluginMutationError::UnsupportedAuthMode); + } + Ok(auth) +} + +fn default_remote_marketplace_name() -> String { + DEFAULT_REMOTE_MARKETPLACE_NAME.to_string() +} + +async fn post_remote_plugin_mutation( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + plugin_id: &str, + action: &str, +) -> Result { + let auth = ensure_codex_backend_auth(auth)?; + let url = remote_plugin_mutation_url(config, plugin_id, action)?; + let client = build_reqwest_client(); + let request = client + .post(url.clone()) + .timeout(REMOTE_PLUGIN_MUTATION_TIMEOUT) + .headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers()); + + let response = request + .send() + .await + .map_err(|source| RemotePluginMutationError::Request { + url: url.clone(), + source, + })?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(RemotePluginMutationError::UnexpectedStatus { url, status, body }); + } + + let parsed: RemotePluginMutationResponse = + serde_json::from_str(&body).map_err(|source| RemotePluginMutationError::Decode { + url: url.clone(), + source, + })?; + let expected_enabled = action == "enable"; + if parsed.id != plugin_id { + return Err(RemotePluginMutationError::UnexpectedPluginId { + expected: plugin_id.to_string(), + actual: parsed.id, + }); + } + if parsed.enabled != expected_enabled { + return Err(RemotePluginMutationError::UnexpectedEnabledState { + plugin_id: plugin_id.to_string(), + expected_enabled, + actual_enabled: parsed.enabled, + }); + } + + Ok(parsed) +} + +fn remote_plugin_mutation_url( + config: &RemotePluginServiceConfig, + plugin_id: &str, + action: &str, +) -> Result { + let mut url = Url::parse(config.chatgpt_base_url.trim_end_matches('/')) + .map_err(RemotePluginMutationError::InvalidBaseUrl)?; + { + let mut segments = url + .path_segments_mut() + .map_err(|()| RemotePluginMutationError::InvalidBaseUrlPath)?; + segments.pop_if_empty(); + segments.push("plugins"); + segments.push(plugin_id); + segments.push(action); + } + Ok(url.to_string()) +} diff --git a/codex-rs/core-plugins/src/startup_sync.rs b/codex-rs/core-plugins/src/startup_sync.rs new file mode 100644 index 0000000000..f03aff93f6 --- /dev/null +++ b/codex-rs/core-plugins/src/startup_sync.rs @@ -0,0 +1,938 @@ +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use std::process::Output; +use std::process::Stdio; +use std::time::Duration; + +use codex_otel::CURATED_PLUGINS_STARTUP_SYNC_FINAL_METRIC; +use codex_otel::CURATED_PLUGINS_STARTUP_SYNC_METRIC; +use reqwest::Client; +use serde::Deserialize; +use tempfile::TempDir; +use tracing::warn; +use zip::ZipArchive; + +use codex_login::default_client::build_reqwest_client; + +const GITHUB_API_BASE_URL: &str = "https://api.github.com"; +const GITHUB_API_ACCEPT_HEADER: &str = "application/vnd.github+json"; +const GITHUB_API_VERSION_HEADER: &str = "2022-11-28"; +const CURATED_PLUGINS_BACKUP_ARCHIVE_API_URL: &str = + "https://chatgpt.com/backend-api/plugins/export/curated"; +const OPENAI_PLUGINS_OWNER: &str = "openai"; +const OPENAI_PLUGINS_REPO: &str = "plugins"; +const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins"; +const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha"; +const CURATED_PLUGINS_BACKUP_ARCHIVE_FALLBACK_VERSION: &str = "export-backup"; +const CURATED_PLUGINS_GIT_TIMEOUT: Duration = Duration::from_secs(30); +const CURATED_PLUGINS_HTTP_TIMEOUT: Duration = Duration::from_secs(30); +const CURATED_PLUGINS_BACKUP_ARCHIVE_TIMEOUT: Duration = Duration::from_secs(30); +// Keep this comfortably above a normal sync attempt so we do not race another Codex process. +const CURATED_PLUGINS_STALE_TEMP_DIR_MAX_AGE: Duration = Duration::from_secs(10 * 60); + +#[derive(Debug, Deserialize)] +struct GitHubRepositorySummary { + default_branch: String, +} + +#[derive(Debug, Deserialize)] +struct GitHubGitRefSummary { + object: GitHubGitRefObject, +} + +#[derive(Debug, Deserialize)] +struct GitHubGitRefObject { + sha: String, +} + +#[derive(Debug, Deserialize)] +struct CuratedPluginsBackupArchiveResponse { + download_url: String, +} + +pub fn curated_plugins_repo_path(codex_home: &Path) -> PathBuf { + codex_home.join(CURATED_PLUGINS_RELATIVE_DIR) +} + +pub fn read_curated_plugins_sha(codex_home: &Path) -> Option { + read_sha_file(curated_plugins_sha_path(codex_home).as_path()) +} + +fn curated_plugins_sha_path(codex_home: &Path) -> PathBuf { + codex_home.join(CURATED_PLUGINS_SHA_FILE) +} + +pub fn sync_openai_plugins_repo(codex_home: &Path) -> Result { + sync_openai_plugins_repo_with_transport_overrides( + codex_home, + "git", + GITHUB_API_BASE_URL, + CURATED_PLUGINS_BACKUP_ARCHIVE_API_URL, + ) +} + +fn sync_openai_plugins_repo_with_transport_overrides( + codex_home: &Path, + git_binary: &str, + api_base_url: &str, + backup_archive_api_url: &str, +) -> Result { + match sync_openai_plugins_repo_via_git(codex_home, git_binary) { + Ok(remote_sha) => { + emit_curated_plugins_startup_sync_metric("git", "success"); + emit_curated_plugins_startup_sync_final_metric("git", "success"); + Ok(remote_sha) + } + Err(err) => { + emit_curated_plugins_startup_sync_metric("git", "failure"); + warn!( + error = %err, + git_binary, + "git sync failed for curated plugin sync; falling back to GitHub HTTP" + ); + match sync_openai_plugins_repo_via_http(codex_home, api_base_url) { + Ok(remote_sha) => { + emit_curated_plugins_startup_sync_metric("http", "success"); + emit_curated_plugins_startup_sync_final_metric("http", "success"); + Ok(remote_sha) + } + Err(http_err) => { + emit_curated_plugins_startup_sync_metric("http", "failure"); + if has_local_curated_plugins_snapshot(codex_home) { + emit_curated_plugins_startup_sync_final_metric("http", "failure"); + warn!( + error = %http_err, + "GitHub HTTP sync failed for curated plugin sync; skipping export archive fallback because a local curated plugins snapshot already exists" + ); + Err(format!( + "git sync failed for curated plugin sync: {err}; GitHub HTTP sync failed for curated plugin sync: {http_err}; export archive fallback skipped because a local curated plugins snapshot already exists" + )) + } else { + // The export archive is a lagging backup path. Only use it to bootstrap a + // missing local curated snapshot, never to refresh an existing one. + warn!( + error = %http_err, + backup_archive_api_url, + "GitHub HTTP sync failed for curated plugin sync; falling back to export archive" + ); + let result = sync_openai_plugins_repo_via_backup_archive( + codex_home, + backup_archive_api_url, + ); + let status = if result.is_ok() { "success" } else { "failure" }; + emit_curated_plugins_startup_sync_metric("export_archive", status); + emit_curated_plugins_startup_sync_final_metric("export_archive", status); + result.map_err(|export_err| { + format!( + "git sync failed for curated plugin sync: {err}; GitHub HTTP sync failed for curated plugin sync: {http_err}; export archive sync failed for curated plugin sync: {export_err}" + ) + }) + } + } + } + } + } +} + +fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Result { + let repo_path = curated_plugins_repo_path(codex_home); + let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE); + let remote_sha = git_ls_remote_head_sha(git_binary)?; + let local_sha = read_local_git_or_sha_file(&repo_path, &sha_path, git_binary); + + if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.join(".git").is_dir() { + return Ok(remote_sha); + } + + let staged_repo_dir = prepare_curated_repo_parent_and_temp_dir(&repo_path)?; + let clone_output = run_git_command_with_timeout( + Command::new(git_binary) + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("clone") + .arg("--depth") + .arg("1") + .arg("https://github.com/openai/plugins.git") + .arg(staged_repo_dir.path()), + "git clone curated plugins repo", + CURATED_PLUGINS_GIT_TIMEOUT, + )?; + ensure_git_success(&clone_output, "git clone curated plugins repo")?; + + let cloned_sha = git_head_sha(staged_repo_dir.path(), git_binary)?; + if cloned_sha != remote_sha { + return Err(format!( + "curated plugins clone HEAD mismatch: expected {remote_sha}, got {cloned_sha}" + )); + } + + ensure_marketplace_manifest_exists(staged_repo_dir.path())?; + activate_curated_repo(&repo_path, staged_repo_dir)?; + write_curated_plugins_sha(&sha_path, &remote_sha)?; + Ok(remote_sha) +} + +fn sync_openai_plugins_repo_via_http( + codex_home: &Path, + api_base_url: &str, +) -> Result { + let repo_path = curated_plugins_repo_path(codex_home); + let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|err| format!("failed to create curated plugins sync runtime: {err}"))?; + let remote_sha = runtime.block_on(fetch_curated_repo_remote_sha(api_base_url))?; + let local_sha = read_sha_file(&sha_path); + + if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.is_dir() { + return Ok(remote_sha); + } + + let staged_repo_dir = prepare_curated_repo_parent_and_temp_dir(&repo_path)?; + let zipball_bytes = runtime.block_on(fetch_curated_repo_zipball(api_base_url, &remote_sha))?; + extract_zipball_to_dir(&zipball_bytes, staged_repo_dir.path())?; + ensure_marketplace_manifest_exists(staged_repo_dir.path())?; + activate_curated_repo(&repo_path, staged_repo_dir)?; + write_curated_plugins_sha(&sha_path, &remote_sha)?; + Ok(remote_sha) +} + +fn sync_openai_plugins_repo_via_backup_archive( + codex_home: &Path, + backup_archive_api_url: &str, +) -> Result { + let repo_path = curated_plugins_repo_path(codex_home); + let sha_path = curated_plugins_sha_path(codex_home); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|err| format!("failed to create curated plugins sync runtime: {err}"))?; + let staged_repo_dir = prepare_curated_repo_parent_and_temp_dir(&repo_path)?; + let zipball_bytes = runtime.block_on(fetch_curated_repo_backup_archive_zip( + backup_archive_api_url, + ))?; + extract_zipball_to_dir(&zipball_bytes, staged_repo_dir.path())?; + ensure_marketplace_manifest_exists(staged_repo_dir.path())?; + let export_version = read_extracted_backup_archive_git_sha(staged_repo_dir.path())? + .unwrap_or_else(|| CURATED_PLUGINS_BACKUP_ARCHIVE_FALLBACK_VERSION.to_string()); + activate_curated_repo(&repo_path, staged_repo_dir)?; + write_curated_plugins_sha(&sha_path, &export_version)?; + Ok(export_version) +} + +pub fn has_local_curated_plugins_snapshot(codex_home: &Path) -> bool { + curated_plugins_repo_path(codex_home) + .join(".agents/plugins/marketplace.json") + .is_file() + && codex_home.join(CURATED_PLUGINS_SHA_FILE).is_file() +} + +fn prepare_curated_repo_parent_and_temp_dir(repo_path: &Path) -> Result { + let Some(parent) = repo_path.parent() else { + return Err(format!( + "failed to determine curated plugins parent directory for {}", + repo_path.display() + )); + }; + std::fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create curated plugins parent directory {}: {err}", + parent.display() + ) + })?; + remove_stale_curated_repo_temp_dirs(parent, CURATED_PLUGINS_STALE_TEMP_DIR_MAX_AGE); + + let clone_dir = tempfile::Builder::new() + .prefix("plugins-clone-") + .tempdir_in(parent) + .map_err(|err| { + format!( + "failed to create temporary curated plugins directory in {}: {err}", + parent.display() + ) + })?; + Ok(clone_dir) +} + +fn remove_stale_curated_repo_temp_dirs(parent: &Path, max_age: Duration) { + let entries = match std::fs::read_dir(parent) { + Ok(entries) => entries, + Err(err) => { + warn!( + error = %err, + parent = %parent.display(), + "failed to list curated plugins temp directory parent for stale cleanup" + ); + return; + } + }; + + for entry in entries.flatten() { + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(err) => { + warn!( + error = %err, + path = %entry.path().display(), + "failed to inspect curated plugins temp directory entry" + ); + continue; + } + }; + if !file_type.is_dir() { + continue; + } + + let path = entry.path(); + let is_plugins_clone_dir = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("plugins-clone-")); + if !is_plugins_clone_dir { + continue; + } + + let metadata = match entry.metadata() { + Ok(metadata) => metadata, + Err(err) => { + warn!( + error = %err, + path = %path.display(), + "failed to read curated plugins temp directory metadata" + ); + continue; + } + }; + let modified = match metadata.modified() { + Ok(modified) => modified, + Err(err) => { + warn!( + error = %err, + path = %path.display(), + "failed to read curated plugins temp directory modification time" + ); + continue; + } + }; + let age = match modified.elapsed() { + Ok(age) => age, + Err(err) => { + warn!( + error = %err, + path = %path.display(), + "failed to compute curated plugins temp directory age" + ); + continue; + } + }; + if age < max_age { + continue; + } + + if let Err(err) = std::fs::remove_dir_all(&path) { + warn!( + error = %err, + path = %path.display(), + "failed to remove stale curated plugins temp directory" + ); + } + } +} + +fn emit_curated_plugins_startup_sync_metric(transport: &'static str, status: &'static str) { + emit_curated_plugins_startup_sync_counter( + CURATED_PLUGINS_STARTUP_SYNC_METRIC, + transport, + status, + ); +} + +fn emit_curated_plugins_startup_sync_final_metric(transport: &'static str, status: &'static str) { + emit_curated_plugins_startup_sync_counter( + CURATED_PLUGINS_STARTUP_SYNC_FINAL_METRIC, + transport, + status, + ); +} + +fn emit_curated_plugins_startup_sync_counter( + metric_name: &str, + transport: &'static str, + status: &'static str, +) { + let Some(metrics) = codex_otel::global() else { + return; + }; + let tags = [("transport", transport), ("status", status)]; + let _ = metrics.counter(metric_name, /*inc*/ 1, &tags); +} + +fn ensure_marketplace_manifest_exists(repo_path: &Path) -> Result<(), String> { + if repo_path.join(".agents/plugins/marketplace.json").is_file() { + return Ok(()); + } + Err(format!( + "curated plugins archive missing marketplace manifest at {}", + repo_path.join(".agents/plugins/marketplace.json").display() + )) +} + +fn activate_curated_repo(repo_path: &Path, staged_repo_dir: TempDir) -> Result<(), String> { + let staged_repo_path = staged_repo_dir.path(); + if repo_path.exists() { + let parent = repo_path.parent().ok_or_else(|| { + format!( + "failed to determine curated plugins parent directory for {}", + repo_path.display() + ) + })?; + let backup_dir = tempfile::Builder::new() + .prefix("plugins-backup-") + .tempdir_in(parent) + .map_err(|err| { + format!( + "failed to create curated plugins backup directory in {}: {err}", + parent.display() + ) + })?; + let backup_repo_path = backup_dir.path().join("repo"); + + std::fs::rename(repo_path, &backup_repo_path).map_err(|err| { + format!( + "failed to move previous curated plugins repo out of the way at {}: {err}", + repo_path.display() + ) + })?; + + if let Err(err) = std::fs::rename(staged_repo_path, repo_path) { + let rollback_result = std::fs::rename(&backup_repo_path, repo_path); + return match rollback_result { + Ok(()) => Err(format!( + "failed to activate new curated plugins repo at {}: {err}", + repo_path.display() + )), + Err(rollback_err) => { + let backup_path = backup_dir.keep().join("repo"); + Err(format!( + "failed to activate new curated plugins repo at {}: {err}; failed to restore previous repo (left at {}): {rollback_err}", + repo_path.display(), + backup_path.display() + )) + } + }; + } + } else { + std::fs::rename(staged_repo_path, repo_path).map_err(|err| { + format!( + "failed to activate curated plugins repo at {}: {err}", + repo_path.display() + ) + })?; + } + + Ok(()) +} + +fn write_curated_plugins_sha(sha_path: &Path, remote_sha: &str) -> Result<(), String> { + if let Some(parent) = sha_path.parent() { + std::fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create curated plugins sha directory {}: {err}", + parent.display() + ) + })?; + } + std::fs::write(sha_path, format!("{remote_sha}\n")).map_err(|err| { + format!( + "failed to write curated plugins sha file {}: {err}", + sha_path.display() + ) + }) +} + +fn read_local_git_or_sha_file( + repo_path: &Path, + sha_path: &Path, + git_binary: &str, +) -> Option { + if repo_path.join(".git").is_dir() + && let Ok(sha) = git_head_sha(repo_path, git_binary) + { + return Some(sha); + } + + read_sha_file(sha_path) +} + +fn git_ls_remote_head_sha(git_binary: &str) -> Result { + let output = run_git_command_with_timeout( + Command::new(git_binary) + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("ls-remote") + .arg("https://github.com/openai/plugins.git") + .arg("HEAD"), + "git ls-remote curated plugins repo", + CURATED_PLUGINS_GIT_TIMEOUT, + )?; + ensure_git_success(&output, "git ls-remote curated plugins repo")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let Some(first_line) = stdout.lines().next() else { + return Err("git ls-remote returned empty output for curated plugins repo".to_string()); + }; + let Some((sha, _)) = first_line.split_once('\t') else { + return Err(format!( + "unexpected git ls-remote output for curated plugins repo: {first_line}" + )); + }; + if sha.is_empty() { + return Err("git ls-remote returned empty sha for curated plugins repo".to_string()); + } + Ok(sha.to_string()) +} + +fn git_head_sha(repo_path: &Path, git_binary: &str) -> Result { + let output = Command::new(git_binary) + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("-C") + .arg(repo_path) + .arg("rev-parse") + .arg("HEAD") + .output() + .map_err(|err| { + format!( + "failed to run git rev-parse HEAD in {}: {err}", + repo_path.display() + ) + })?; + ensure_git_success(&output, "git rev-parse HEAD")?; + + let sha = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if sha.is_empty() { + return Err(format!( + "git rev-parse HEAD returned empty output in {}", + repo_path.display() + )); + } + Ok(sha) +} + +fn run_git_command_with_timeout( + command: &mut Command, + context: &str, + timeout: Duration, +) -> Result { + let mut child = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| format!("failed to run {context}: {err}"))?; + + let start = std::time::Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_)) => { + return child + .wait_with_output() + .map_err(|err| format!("failed to wait for {context}: {err}")); + } + Ok(None) => {} + Err(err) => return Err(format!("failed to poll {context}: {err}")), + } + + if start.elapsed() >= timeout { + match child.try_wait() { + Ok(Some(_)) => { + return child + .wait_with_output() + .map_err(|err| format!("failed to wait for {context}: {err}")); + } + Ok(None) => {} + Err(err) => return Err(format!("failed to poll {context}: {err}")), + } + + let _ = child.kill(); + let output = child + .wait_with_output() + .map_err(|err| format!("failed to wait for {context} after timeout: {err}"))?; + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return if stderr.is_empty() { + Err(format!("{context} timed out after {}s", timeout.as_secs())) + } else { + Err(format!( + "{context} timed out after {}s: {stderr}", + timeout.as_secs() + )) + }; + } + + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn ensure_git_success(output: &Output, context: &str) -> Result<(), String> { + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + Err(format!("{context} failed with status {}", output.status)) + } else { + Err(format!( + "{context} failed with status {}: {stderr}", + output.status + )) + } +} + +async fn fetch_curated_repo_remote_sha(api_base_url: &str) -> Result { + let api_base_url = api_base_url.trim_end_matches('/'); + let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}"); + let client = build_reqwest_client(); + let repo_body = fetch_github_text(&client, &repo_url, "get curated plugins repository").await?; + let repo_summary: GitHubRepositorySummary = + serde_json::from_str(&repo_body).map_err(|err| { + format!("failed to parse curated plugins repository response from {repo_url}: {err}") + })?; + if repo_summary.default_branch.is_empty() { + return Err(format!( + "curated plugins repository response from {repo_url} did not include a default branch" + )); + } + + let git_ref_url = format!("{repo_url}/git/ref/heads/{}", repo_summary.default_branch); + let git_ref_body = + fetch_github_text(&client, &git_ref_url, "get curated plugins HEAD ref").await?; + let git_ref: GitHubGitRefSummary = serde_json::from_str(&git_ref_body).map_err(|err| { + format!("failed to parse curated plugins ref response from {git_ref_url}: {err}") + })?; + if git_ref.object.sha.is_empty() { + return Err(format!( + "curated plugins ref response from {git_ref_url} did not include a HEAD sha" + )); + } + + Ok(git_ref.object.sha) +} + +async fn fetch_curated_repo_zipball( + api_base_url: &str, + remote_sha: &str, +) -> Result, String> { + let api_base_url = api_base_url.trim_end_matches('/'); + let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}"); + let zipball_url = format!("{repo_url}/zipball/{remote_sha}"); + let client = build_reqwest_client(); + fetch_github_bytes(&client, &zipball_url, "download curated plugins archive").await +} + +async fn fetch_curated_repo_backup_archive_zip( + backup_archive_api_url: &str, +) -> Result, String> { + let client = build_reqwest_client(); + let export_body = fetch_public_text( + &client, + backup_archive_api_url, + "get curated plugins export archive metadata", + ) + .await?; + let export_response: CuratedPluginsBackupArchiveResponse = serde_json::from_str(&export_body) + .map_err(|err| { + format!( + "failed to parse curated plugins backup archive response from {backup_archive_api_url}: {err}" + ) + })?; + if export_response.download_url.is_empty() { + return Err(format!( + "curated plugins backup archive response from {backup_archive_api_url} did not include a download URL" + )); + } + + fetch_public_bytes( + &client, + &export_response.download_url, + "download curated plugins export archive", + ) + .await +} + +fn read_extracted_backup_archive_git_sha(repo_path: &Path) -> Result, String> { + let git_dir = repo_path.join(".git"); + if !git_dir.is_dir() { + return Ok(None); + } + + let head_path = git_dir.join("HEAD"); + let head = std::fs::read_to_string(&head_path).map_err(|err| { + format!( + "failed to read curated plugins backup archive git HEAD {}: {err}", + head_path.display() + ) + })?; + let head = head.trim(); + if head.is_empty() { + return Err(format!( + "curated plugins backup archive git HEAD is empty at {}", + head_path.display() + )); + } + + if let Some(reference) = head.strip_prefix("ref: ") { + let reference = validate_backup_archive_git_ref(reference.trim())?; + return read_git_ref_sha(&git_dir, reference).map(Some); + } + + Ok(Some(head.to_string())) +} + +fn validate_backup_archive_git_ref(reference: &str) -> Result<&str, String> { + if !reference.starts_with("refs/") { + return Err(format!( + "curated plugins backup archive git ref must stay under refs/: {reference}" + )); + } + + let path = Path::new(reference); + if path.is_absolute() { + return Err(format!( + "curated plugins backup archive git ref must be relative: {reference}" + )); + } + + for component in path.components() { + match component { + std::path::Component::Normal(_) => {} + _ => { + return Err(format!( + "curated plugins backup archive git ref contains invalid path components: {reference}" + )); + } + } + } + + Ok(reference) +} + +fn read_git_ref_sha(git_dir: &Path, reference: &str) -> Result { + let ref_path = git_dir.join(reference); + if let Ok(sha) = std::fs::read_to_string(&ref_path) { + let sha = sha.trim(); + if sha.is_empty() { + return Err(format!( + "curated plugins backup archive git ref {reference} is empty at {}", + ref_path.display() + )); + } + return Ok(sha.to_string()); + } + + let packed_refs_path = git_dir.join("packed-refs"); + if let Ok(packed_refs) = std::fs::read_to_string(&packed_refs_path) + && let Some(sha) = packed_refs.lines().find_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('^') { + return None; + } + let (sha, candidate_ref) = trimmed.split_once(' ')?; + (candidate_ref == reference).then_some(sha.to_string()) + }) + { + return Ok(sha); + } + + Err(format!( + "failed to resolve curated plugins backup archive git ref {reference} from {}", + git_dir.display() + )) +} + +async fn fetch_github_text(client: &Client, url: &str, context: &str) -> Result { + let response = github_request(client, url) + .send() + .await + .map_err(|err| format!("failed to {context} from {url}: {err}"))?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(format!( + "{context} from {url} failed with status {status}: {body}" + )); + } + Ok(body) +} + +async fn fetch_github_bytes(client: &Client, url: &str, context: &str) -> Result, String> { + let response = github_request(client, url) + .send() + .await + .map_err(|err| format!("failed to {context} from {url}: {err}"))?; + let status = response.status(); + let body = response + .bytes() + .await + .map_err(|err| format!("failed to read {context} response from {url}: {err}"))?; + if !status.is_success() { + let body_text = String::from_utf8_lossy(&body); + return Err(format!( + "{context} from {url} failed with status {status}: {body_text}" + )); + } + Ok(body.to_vec()) +} + +async fn fetch_public_text(client: &Client, url: &str, context: &str) -> Result { + let response = client + .get(url) + .timeout(CURATED_PLUGINS_BACKUP_ARCHIVE_TIMEOUT) + .send() + .await + .map_err(|err| format!("failed to {context} from {url}: {err}"))?; + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(format!( + "{context} from {url} failed with status {status}: {body}" + )); + } + Ok(body) +} + +async fn fetch_public_bytes(client: &Client, url: &str, context: &str) -> Result, String> { + let response = client + .get(url) + .timeout(CURATED_PLUGINS_BACKUP_ARCHIVE_TIMEOUT) + .send() + .await + .map_err(|err| format!("failed to {context} from {url}: {err}"))?; + let status = response.status(); + let body = response + .bytes() + .await + .map_err(|err| format!("failed to read {context} response from {url}: {err}"))?; + if !status.is_success() { + let body_text = String::from_utf8_lossy(&body); + return Err(format!( + "{context} from {url} failed with status {status}: {body_text}" + )); + } + Ok(body.to_vec()) +} + +fn github_request(client: &Client, url: &str) -> reqwest::RequestBuilder { + client + .get(url) + .timeout(CURATED_PLUGINS_HTTP_TIMEOUT) + .header("accept", GITHUB_API_ACCEPT_HEADER) + .header("x-github-api-version", GITHUB_API_VERSION_HEADER) +} + +fn read_sha_file(sha_path: &Path) -> Option { + std::fs::read_to_string(sha_path) + .ok() + .map(|sha| sha.trim().to_string()) + .filter(|sha| !sha.is_empty()) +} + +fn extract_zipball_to_dir(bytes: &[u8], destination: &Path) -> Result<(), String> { + std::fs::create_dir_all(destination).map_err(|err| { + format!( + "failed to create curated plugins extraction directory {}: {err}", + destination.display() + ) + })?; + + let cursor = std::io::Cursor::new(bytes); + let mut archive = ZipArchive::new(cursor) + .map_err(|err| format!("failed to open curated plugins zip archive: {err}"))?; + + for index in 0..archive.len() { + let mut entry = archive + .by_index(index) + .map_err(|err| format!("failed to read curated plugins zip entry: {err}"))?; + let Some(relative_path) = entry.enclosed_name() else { + return Err(format!( + "curated plugins zip entry `{}` escapes extraction root", + entry.name() + )); + }; + + let mut components = relative_path.components(); + let Some(std::path::Component::Normal(_)) = components.next() else { + continue; + }; + + let output_relative = components.fold(PathBuf::new(), |mut path, component| { + if let std::path::Component::Normal(segment) = component { + path.push(segment); + } + path + }); + if output_relative.as_os_str().is_empty() { + continue; + } + + let output_path = destination.join(&output_relative); + if entry.is_dir() { + std::fs::create_dir_all(&output_path).map_err(|err| { + format!( + "failed to create curated plugins directory {}: {err}", + output_path.display() + ) + })?; + continue; + } + + if let Some(parent) = output_path.parent() { + std::fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create curated plugins directory {}: {err}", + parent.display() + ) + })?; + } + let mut output = std::fs::File::create(&output_path).map_err(|err| { + format!( + "failed to create curated plugins file {}: {err}", + output_path.display() + ) + })?; + std::io::copy(&mut entry, &mut output).map_err(|err| { + format!( + "failed to write curated plugins file {}: {err}", + output_path.display() + ) + })?; + apply_zip_permissions(&entry, &output_path)?; + } + + Ok(()) +} + +#[cfg(unix)] +fn apply_zip_permissions(entry: &zip::read::ZipFile<'_>, output_path: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + + let Some(mode) = entry.unix_mode() else { + return Ok(()); + }; + std::fs::set_permissions(output_path, std::fs::Permissions::from_mode(mode)).map_err(|err| { + format!( + "failed to set permissions on curated plugins file {}: {err}", + output_path.display() + ) + }) +} + +#[cfg(not(unix))] +fn apply_zip_permissions( + _entry: &zip::read::ZipFile<'_>, + _output_path: &Path, +) -> Result<(), String> { + Ok(()) +} + +#[cfg(test)] +#[path = "startup_sync_tests.rs"] +mod tests; diff --git a/codex-rs/core-plugins/src/startup_sync_tests.rs b/codex-rs/core-plugins/src/startup_sync_tests.rs new file mode 100644 index 0000000000..a9388e3fce --- /dev/null +++ b/codex-rs/core-plugins/src/startup_sync_tests.rs @@ -0,0 +1,769 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use tempfile::tempdir; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; +use zip::ZipWriter; +use zip::write::SimpleFileOptions; + +const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567"; + +fn write_file(path: &Path, contents: &str) { + std::fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap(); + std::fs::write(path, contents).unwrap(); +} + +fn write_curated_plugin(root: &Path, plugin_name: &str) { + let plugin_root = root.join("plugins").join(plugin_name); + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + &format!(r#"{{"name":"{plugin_name}"}}"#), + ); +} + +fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) { + let plugins = plugin_names + .iter() + .map(|plugin_name| { + format!( + r#"{{ + "name": "{plugin_name}", + "source": {{ + "source": "local", + "path": "./plugins/{plugin_name}" + }} + }}"# + ) + }) + .collect::>() + .join(",\n"); + write_file( + &root.join(".agents/plugins/marketplace.json"), + &format!( + r#"{{ + "name": "openai-curated", + "plugins": [ +{plugins} + ] +}}"# + ), + ); + for plugin_name in plugin_names { + write_curated_plugin(root, plugin_name); + } +} + +fn write_curated_plugin_sha(codex_home: &Path) { + write_file( + &codex_home.join(".tmp/plugins.sha"), + &format!("{TEST_CURATED_PLUGIN_SHA}\n"), + ); +} + +fn has_plugins_clone_dirs(codex_home: &Path) -> bool { + let Ok(entries) = std::fs::read_dir(codex_home.join(".tmp")) else { + return false; + }; + + entries.flatten().any(|entry| { + let path = entry.path(); + path.is_dir() + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("plugins-clone-")) + }) +} + +#[cfg(unix)] +fn write_executable_script(path: &Path, contents: &str) { + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + std::fs::write(path, contents).expect("write script"); + #[cfg(unix)] + { + let mut permissions = std::fs::metadata(path).expect("metadata").permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions).expect("chmod"); + } +} + +async fn mount_github_repo_and_ref(server: &MockServer, sha: &str) { + Mock::given(method("GET")) + .and(path("/repos/openai/plugins")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#)) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path("/repos/openai/plugins/git/ref/heads/main")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)), + ) + .mount(server) + .await; +} + +async fn mount_github_zipball(server: &MockServer, sha: &str, bytes: Vec) { + Mock::given(method("GET")) + .and(path(format!("/repos/openai/plugins/zipball/{sha}"))) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/zip") + .set_body_bytes(bytes), + ) + .mount(server) + .await; +} + +async fn mount_export_archive(server: &MockServer, bytes: Vec) -> String { + let export_api_url = format!("{}/backend-api/plugins/export/curated", server.uri()); + Mock::given(method("GET")) + .and(path("/backend-api/plugins/export/curated")) + .respond_with(ResponseTemplate::new(200).set_body_string(format!( + r#"{{"download_url":"{}/files/curated-plugins.zip"}}"#, + server.uri() + ))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path("/files/curated-plugins.zip")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/zip") + .set_body_bytes(bytes), + ) + .mount(server) + .await; + export_api_url +} + +async fn run_sync_with_transport_overrides( + codex_home: PathBuf, + git_binary: impl Into, + api_base_url: impl Into, + backup_archive_api_url: impl Into, +) -> Result { + let git_binary = git_binary.into(); + let api_base_url = api_base_url.into(); + let backup_archive_api_url = backup_archive_api_url.into(); + tokio::task::spawn_blocking(move || { + sync_openai_plugins_repo_with_transport_overrides( + codex_home.as_path(), + &git_binary, + &api_base_url, + &backup_archive_api_url, + ) + }) + .await + .expect("sync task should join") +} + +async fn run_http_sync( + codex_home: PathBuf, + api_base_url: impl Into, +) -> Result { + let api_base_url = api_base_url.into(); + tokio::task::spawn_blocking(move || { + sync_openai_plugins_repo_via_http(codex_home.as_path(), &api_base_url) + }) + .await + .expect("sync task should join") +} + +fn assert_curated_gmail_repo(repo_path: &Path) { + assert!(repo_path.join(".agents/plugins/marketplace.json").is_file()); + assert!( + repo_path + .join("plugins/gmail/.codex-plugin/plugin.json") + .is_file() + ); +} + +#[test] +fn curated_plugins_repo_path_uses_codex_home_tmp_dir() { + let tmp = tempdir().expect("tempdir"); + assert_eq!( + curated_plugins_repo_path(tmp.path()), + tmp.path().join(".tmp/plugins") + ); +} + +#[test] +fn read_curated_plugins_sha_reads_trimmed_sha_file() { + let tmp = tempdir().expect("tempdir"); + std::fs::create_dir_all(tmp.path().join(".tmp")).expect("create tmp"); + std::fs::write(tmp.path().join(".tmp/plugins.sha"), "abc123\n").expect("write sha"); + + assert_eq!( + read_curated_plugins_sha(tmp.path()).as_deref(), + Some("abc123") + ); +} + +#[cfg(unix)] +#[test] +fn remove_stale_curated_repo_temp_dirs_removes_only_matching_directories() { + use std::os::unix::ffi::OsStrExt; + use std::time::SystemTime; + + fn set_dir_mtime(path: &Path, age: Duration) -> Result<(), Box> { + let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?; + let modified_at = now.saturating_sub(age); + let tv_sec = i64::try_from(modified_at.as_secs())?; + let ts = libc::timespec { tv_sec, tv_nsec: 0 }; + let times = [ts, ts]; + let c_path = std::ffi::CString::new(path.as_os_str().as_bytes())?; + let result = unsafe { libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), 0) }; + if result != 0 { + return Err(std::io::Error::last_os_error().into()); + } + Ok(()) + } + + let tmp = tempdir().expect("tempdir"); + let parent = tmp.path().join(".tmp"); + let stale_clone_dir = parent.join("plugins-clone-stale"); + let fresh_clone_dir = parent.join("plugins-clone-fresh"); + let unrelated_dir = parent.join("plugins-cache"); + + std::fs::create_dir_all(&stale_clone_dir).expect("create stale clone dir"); + std::fs::create_dir_all(&fresh_clone_dir).expect("create fresh clone dir"); + std::fs::create_dir_all(&unrelated_dir).expect("create unrelated dir"); + set_dir_mtime( + &stale_clone_dir, + CURATED_PLUGINS_STALE_TEMP_DIR_MAX_AGE + Duration::from_secs(60), + ) + .expect("age stale clone dir"); + set_dir_mtime(&fresh_clone_dir, Duration::ZERO).expect("age fresh clone dir"); + + remove_stale_curated_repo_temp_dirs(&parent, CURATED_PLUGINS_STALE_TEMP_DIR_MAX_AGE); + + assert!(!stale_clone_dir.exists()); + assert!(fresh_clone_dir.is_dir()); + assert!(unrelated_dir.is_dir()); +} + +#[cfg(unix)] +#[test] +fn sync_openai_plugins_repo_prefers_git_when_available() { + let tmp = tempdir().expect("tempdir"); + let bin_dir = tempfile::Builder::new() + .prefix("fake-git-") + .tempdir() + .expect("tempdir"); + let git_path = bin_dir.path().join("git"); + let sha = "0123456789abcdef0123456789abcdef01234567"; + + write_executable_script( + &git_path, + &format!( + r#"#!/bin/sh +if [ "$1" = "ls-remote" ]; then + printf '%s\tHEAD\n' "{sha}" + exit 0 +fi +if [ "$1" = "clone" ]; then + dest="$5" + mkdir -p "$dest/.git" "$dest/.agents/plugins" "$dest/plugins/gmail/.codex-plugin" + cat > "$dest/.agents/plugins/marketplace.json" <<'EOF' +{{"name":"openai-curated","plugins":[{{"name":"gmail","source":{{"source":"local","path":"./plugins/gmail"}}}}]}} +EOF + printf '%s\n' '{{"name":"gmail"}}' > "$dest/plugins/gmail/.codex-plugin/plugin.json" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ] && [ "$4" = "HEAD" ]; then + printf '%s\n' "{sha}" + exit 0 +fi +echo "unexpected git invocation: $@" >&2 +exit 1 +"# + ), + ); + + let synced_sha = sync_openai_plugins_repo_with_transport_overrides( + tmp.path(), + git_path.to_str().expect("utf8 path"), + "http://127.0.0.1:9", + "http://127.0.0.1:9/backend-api/plugins/export/curated", + ) + .expect("git sync should succeed"); + + assert_eq!(synced_sha, sha); + let repo_path = curated_plugins_repo_path(tmp.path()); + assert!(repo_path.join(".git").is_dir()); + assert_curated_gmail_repo(&repo_path); + assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); +} + +#[cfg(unix)] +#[test] +fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { + let tmp = tempdir().expect("tempdir"); + let repo_root = tempfile::Builder::new() + .prefix("curated-repo-success-") + .tempdir() + .expect("tempdir"); + let work_repo = repo_root.path().join("work/plugins"); + let remote_repo = repo_root.path().join("remotes/openai/plugins.git"); + std::fs::create_dir_all(work_repo.join(".agents/plugins")).expect("create marketplace dir"); + std::fs::create_dir_all(work_repo.join("plugins/gmail/.codex-plugin")) + .expect("create plugin dir"); + std::fs::write( + work_repo.join(".agents/plugins/marketplace.json"), + r#"{"name":"openai-curated","plugins":[{"name":"gmail","source":{"source":"local","path":"./plugins/gmail"}}]}"#, + ) + .expect("write marketplace"); + std::fs::write( + work_repo.join("plugins/gmail/.codex-plugin/plugin.json"), + r#"{"name":"gmail"}"#, + ) + .expect("write plugin manifest"); + + let init_status = Command::new("git") + .arg("-C") + .arg(&work_repo) + .arg("init") + .status() + .expect("run git init"); + assert!(init_status.success()); + + let add_status = Command::new("git") + .arg("-C") + .arg(&work_repo) + .arg("add") + .arg(".") + .status() + .expect("run git add"); + assert!(add_status.success()); + + let commit_status = Command::new("git") + .arg("-C") + .arg(&work_repo) + .arg("-c") + .arg("user.name=Codex Test") + .arg("-c") + .arg("user.email=codex@example.com") + .arg("commit") + .arg("-m") + .arg("init") + .status() + .expect("run git commit"); + assert!(commit_status.success()); + + std::fs::create_dir_all(remote_repo.parent().expect("remote parent")) + .expect("create remote parent"); + let clone_status = Command::new("git") + .arg("clone") + .arg("--bare") + .arg(&work_repo) + .arg(&remote_repo) + .status() + .expect("run git clone --bare"); + assert!(clone_status.success()); + + let sha_output = Command::new("git") + .arg("-C") + .arg(&work_repo) + .arg("rev-parse") + .arg("HEAD") + .output() + .expect("run git rev-parse"); + assert!(sha_output.status.success()); + let sha = String::from_utf8_lossy(&sha_output.stdout) + .trim() + .to_string(); + + let git_config_path = repo_root.path().join("git-rewrite.conf"); + std::fs::write( + &git_config_path, + format!( + "[url \"file://{}/\"]\n insteadOf = https://github.com/\n", + repo_root.path().join("remotes").display() + ), + ) + .expect("write git config"); + + let bin_dir = tempfile::Builder::new() + .prefix("git-rewrite-wrapper-") + .tempdir() + .expect("tempdir"); + let git_wrapper = bin_dir.path().join("git"); + write_executable_script( + &git_wrapper, + &format!( + "#!/bin/sh\nGIT_CONFIG_GLOBAL='{}' exec git \"$@\"\n", + git_config_path.display() + ), + ); + + let synced_sha = + sync_openai_plugins_repo_via_git(tmp.path(), git_wrapper.to_str().expect("utf8 path")) + .expect("git sync should succeed"); + + assert_eq!(synced_sha, sha); + assert_curated_gmail_repo(&curated_plugins_repo_path(tmp.path())); + assert_eq!( + read_curated_plugins_sha(tmp.path()).as_deref(), + Some(sha.as_str()) + ); + assert!(!has_plugins_clone_dirs(tmp.path())); +} + +#[tokio::test] +async fn sync_openai_plugins_repo_falls_back_to_http_when_git_is_unavailable() { + let tmp = tempdir().expect("tempdir"); + let server = MockServer::start().await; + let sha = "0123456789abcdef0123456789abcdef01234567"; + + mount_github_repo_and_ref(&server, sha).await; + mount_github_zipball(&server, sha, curated_repo_zipball_bytes(sha)).await; + + let synced_sha = run_sync_with_transport_overrides( + tmp.path().to_path_buf(), + "missing-git-for-test", + server.uri(), + "http://127.0.0.1:9/backend-api/plugins/export/curated", + ) + .await + .expect("fallback sync should succeed"); + + let repo_path = curated_plugins_repo_path(tmp.path()); + assert_eq!(synced_sha, sha); + assert_curated_gmail_repo(&repo_path); + assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); +} + +#[cfg(unix)] +#[tokio::test] +async fn sync_openai_plugins_repo_falls_back_to_http_when_git_sync_fails() { + let tmp = tempdir().expect("tempdir"); + let bin_dir = tempfile::Builder::new() + .prefix("fake-git-fail-") + .tempdir() + .expect("tempdir"); + let git_path = bin_dir.path().join("git"); + let sha = "0123456789abcdef0123456789abcdef01234567"; + + write_executable_script( + &git_path, + r#"#!/bin/sh +echo "simulated git failure" >&2 +exit 1 +"#, + ); + + let server = MockServer::start().await; + mount_github_repo_and_ref(&server, sha).await; + mount_github_zipball(&server, sha, curated_repo_zipball_bytes(sha)).await; + + let synced_sha = run_sync_with_transport_overrides( + tmp.path().to_path_buf(), + git_path.to_str().expect("utf8 path"), + server.uri(), + "http://127.0.0.1:9/backend-api/plugins/export/curated", + ) + .await + .expect("fallback sync should succeed"); + + let repo_path = curated_plugins_repo_path(tmp.path()); + assert_eq!(synced_sha, sha); + assert_curated_gmail_repo(&repo_path); + assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); +} + +#[cfg(unix)] +#[test] +fn sync_openai_plugins_repo_via_git_cleans_up_staged_dir_on_clone_failure() { + let tmp = tempdir().expect("tempdir"); + let bin_dir = tempfile::Builder::new() + .prefix("fake-git-partial-fail-") + .tempdir() + .expect("tempdir"); + let git_path = bin_dir.path().join("git"); + let sha = "0123456789abcdef0123456789abcdef01234567"; + + write_executable_script( + &git_path, + &format!( + r#"#!/bin/sh +if [ "$1" = "ls-remote" ]; then + printf '%s\tHEAD\n' "{sha}" + exit 0 +fi +if [ "$1" = "clone" ]; then + dest="$5" + mkdir -p "$dest/.git" + echo "fatal: early EOF" >&2 + exit 128 +fi +echo "unexpected git invocation: $@" >&2 +exit 1 +"# + ), + ); + + let err = sync_openai_plugins_repo_via_git(tmp.path(), git_path.to_str().expect("utf8 path")) + .expect_err("git sync should fail"); + + assert!(err.contains("fatal: early EOF")); + assert!(!has_plugins_clone_dirs(tmp.path())); +} + +#[tokio::test] +async fn sync_openai_plugins_repo_via_http_cleans_up_staged_dir_on_extract_failure() { + let tmp = tempdir().expect("tempdir"); + let server = MockServer::start().await; + let sha = "0123456789abcdef0123456789abcdef01234567"; + + mount_github_repo_and_ref(&server, sha).await; + mount_github_zipball(&server, sha, b"not a zip archive".to_vec()).await; + + let err = run_http_sync(tmp.path().to_path_buf(), server.uri()) + .await + .expect_err("http sync should fail"); + + assert!(err.contains("failed to open curated plugins zip archive")); + assert!(!has_plugins_clone_dirs(tmp.path())); +} + +#[tokio::test] +async fn sync_openai_plugins_repo_skips_archive_download_when_sha_matches() { + let tmp = tempdir().expect("tempdir"); + let repo_path = curated_plugins_repo_path(tmp.path()); + std::fs::create_dir_all(repo_path.join(".agents/plugins")).expect("create repo"); + std::fs::write( + repo_path.join(".agents/plugins/marketplace.json"), + r#"{"name":"openai-curated","plugins":[]}"#, + ) + .expect("write marketplace"); + std::fs::create_dir_all(tmp.path().join(".tmp")).expect("create tmp"); + let sha = "fedcba9876543210fedcba9876543210fedcba98"; + std::fs::write(tmp.path().join(".tmp/plugins.sha"), format!("{sha}\n")).expect("write sha"); + + let server = MockServer::start().await; + mount_github_repo_and_ref(&server, sha).await; + + run_sync_with_transport_overrides( + tmp.path().to_path_buf(), + "missing-git-for-test", + server.uri(), + "http://127.0.0.1:9/backend-api/plugins/export/curated", + ) + .await + .expect("sync should succeed"); + + assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); + assert!(repo_path.join(".agents/plugins/marketplace.json").is_file()); +} + +#[tokio::test] +async fn sync_openai_plugins_repo_falls_back_to_export_archive_when_no_snapshot_exists() { + let tmp = tempdir().expect("tempdir"); + let server = MockServer::start().await; + let export_sha = "1111111111111111111111111111111111111111"; + + Mock::given(method("GET")) + .and(path("/repos/openai/plugins")) + .respond_with(ResponseTemplate::new(500).set_body_string("github repo lookup failed")) + .mount(&server) + .await; + let export_api_url = + mount_export_archive(&server, curated_repo_backup_archive_zip_bytes(export_sha)).await; + + let synced_sha = run_sync_with_transport_overrides( + tmp.path().to_path_buf(), + "missing-git-for-test", + server.uri(), + export_api_url, + ) + .await + .expect("export fallback sync should succeed"); + + let repo_path = curated_plugins_repo_path(tmp.path()); + assert_eq!(synced_sha, export_sha); + assert_curated_gmail_repo(&repo_path); + assert_eq!( + read_curated_plugins_sha(tmp.path()).as_deref(), + Some(export_sha) + ); +} + +#[tokio::test] +async fn sync_openai_plugins_repo_skips_export_archive_when_snapshot_exists() { + let tmp = tempdir().expect("tempdir"); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &["linear"]); + write_curated_plugin_sha(tmp.path()); + + let plugin_manifest_path = curated_root.join("plugins/linear/.codex-plugin/plugin.json"); + let original_manifest = + std::fs::read_to_string(&plugin_manifest_path).expect("read existing plugin manifest"); + + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/repos/openai/plugins")) + .respond_with(ResponseTemplate::new(500).set_body_string("github repo lookup failed")) + .mount(&server) + .await; + let export_api_url = mount_export_archive( + &server, + curated_repo_backup_archive_zip_bytes("2222222222222222222222222222222222222222"), + ) + .await; + + let err = run_sync_with_transport_overrides( + tmp.path().to_path_buf(), + "missing-git-for-test", + server.uri(), + export_api_url, + ) + .await + .expect_err("existing snapshot should suppress export fallback"); + + assert!(err.contains("export archive fallback skipped")); + assert_eq!( + std::fs::read_to_string(&plugin_manifest_path).expect("read plugin manifest after sync"), + original_manifest + ); + assert_eq!( + read_curated_plugins_sha(tmp.path()).as_deref(), + Some(TEST_CURATED_PLUGIN_SHA) + ); +} + +#[test] +fn read_extracted_backup_archive_git_sha_reads_head_ref_from_extracted_repo() { + let tmp = tempdir().expect("tempdir"); + let git_dir = tmp.path().join(".git/refs/heads"); + std::fs::create_dir_all(&git_dir).expect("create git ref dir"); + std::fs::write(tmp.path().join(".git/HEAD"), "ref: refs/heads/main\n").expect("write HEAD"); + std::fs::write( + git_dir.join("main"), + "3333333333333333333333333333333333333333\n", + ) + .expect("write main ref"); + + assert_eq!( + read_extracted_backup_archive_git_sha(tmp.path()) + .expect("read extracted backup archive git sha"), + Some("3333333333333333333333333333333333333333".to_string()) + ); +} + +#[test] +fn read_extracted_backup_archive_git_sha_rejects_non_refs_head_target() { + let tmp = tempdir().expect("tempdir"); + std::fs::create_dir_all(tmp.path().join(".git")).expect("create git dir"); + std::fs::write(tmp.path().join(".git/HEAD"), "ref: HEAD\n").expect("write HEAD"); + + let err = read_extracted_backup_archive_git_sha(tmp.path()) + .expect_err("non-refs target should be rejected"); + + assert!(err.contains("must stay under refs/")); +} + +#[test] +fn read_extracted_backup_archive_git_sha_rejects_path_traversal_ref() { + let tmp = tempdir().expect("tempdir"); + std::fs::create_dir_all(tmp.path().join(".git")).expect("create git dir"); + std::fs::write(tmp.path().join(".git/HEAD"), "ref: refs/heads/../../evil\n") + .expect("write HEAD"); + + let err = read_extracted_backup_archive_git_sha(tmp.path()) + .expect_err("path traversal ref should be rejected"); + + assert!(err.contains("invalid path components")); +} + +fn curated_repo_zipball_bytes(sha: &str) -> Vec { + let cursor = std::io::Cursor::new(Vec::new()); + let mut writer = ZipWriter::new(cursor); + let options = SimpleFileOptions::default(); + let root = format!("openai-plugins-{sha}"); + writer + .start_file(format!("{root}/.agents/plugins/marketplace.json"), options) + .expect("start marketplace entry"); + writer + .write_all( + br#"{ + "name": "openai-curated", + "plugins": [ + { + "name": "gmail", + "source": { + "source": "local", + "path": "./plugins/gmail" + } + } + ] +}"#, + ) + .expect("write marketplace"); + writer + .start_file( + format!("{root}/plugins/gmail/.codex-plugin/plugin.json"), + options, + ) + .expect("start plugin manifest entry"); + writer + .write_all(br#"{"name":"gmail"}"#) + .expect("write plugin manifest"); + + writer.finish().expect("finish zip writer").into_inner() +} + +fn curated_repo_backup_archive_zip_bytes(sha: &str) -> Vec { + let cursor = std::io::Cursor::new(Vec::new()); + let mut writer = ZipWriter::new(cursor); + let options = SimpleFileOptions::default(); + + writer + .start_file("plugins/.git/HEAD", options) + .expect("start HEAD entry"); + writer + .write_all(b"ref: refs/heads/main\n") + .expect("write HEAD"); + writer + .start_file("plugins/.git/refs/heads/main", options) + .expect("start main ref entry"); + writer + .write_all(format!("{sha}\n").as_bytes()) + .expect("write main ref"); + writer + .start_file("plugins/.agents/plugins/marketplace.json", options) + .expect("start marketplace entry"); + writer + .write_all( + br#"{ + "name": "openai-curated", + "plugins": [ + { + "name": "gmail", + "source": { + "source": "local", + "path": "./plugins/gmail" + } + } + ] +}"#, + ) + .expect("write marketplace"); + writer + .start_file("plugins/plugins/gmail/.codex-plugin/plugin.json", options) + .expect("start plugin manifest entry"); + writer + .write_all(br#"{"name":"gmail"}"#) + .expect("write plugin manifest"); + + writer.finish().expect("finish zip writer").into_inner() +} diff --git a/codex-rs/core-skills/Cargo.toml b/codex-rs/core-skills/Cargo.toml index 355374114a..4324d29dee 100644 --- a/codex-rs/core-skills/Cargo.toml +++ b/codex-rs/core-skills/Cargo.toml @@ -19,6 +19,7 @@ codex-app-server-protocol = { workspace = true } codex-config = { workspace = true } codex-exec-server = { workspace = true } codex-login = { workspace = true } +codex-model-provider = { workspace = true } codex-otel = { workspace = true } codex-protocol = { workspace = true } codex-skills = { workspace = true } diff --git a/codex-rs/core-skills/src/lib.rs b/codex-rs/core-skills/src/lib.rs index 06ced0d5d4..eec3a5f054 100644 --- a/codex-rs/core-skills/src/lib.rs +++ b/codex-rs/core-skills/src/lib.rs @@ -23,7 +23,12 @@ pub use model::SkillMetadata; pub use model::SkillPolicy; pub use model::filter_skill_load_outcome_for_product; pub use render::AvailableSkills; +pub use render::SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS; +pub use render::SKILLS_HOW_TO_USE_WITH_ALIASES; +pub use render::SKILLS_INTRO_WITH_ABSOLUTE_PATHS; +pub use render::SKILLS_INTRO_WITH_ALIASES; pub use render::SkillMetadataBudget; pub use render::SkillRenderReport; pub use render::build_available_skills; pub use render::default_skill_metadata_budget; +pub use render::render_available_skills_body; diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index 2cae6a4b0b..d7a69e8a25 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -159,13 +159,22 @@ where I: IntoIterator, { let mut outcome = SkillLoadOutcome::default(); + let mut skill_roots: Vec = Vec::new(); + let mut skill_root_by_path: HashMap = HashMap::new(); let mut file_systems_by_skill_path: HashMap> = HashMap::new(); for root in roots { + let root_path = canonicalize_for_skill_identity(&root.path); let fs = root.file_system; let skills_before_root = outcome.skills.len(); - discover_skills_under_root(fs.as_ref(), &root.path, root.scope, &mut outcome).await; + discover_skills_under_root(fs.as_ref(), &root_path, root.scope, &mut outcome).await; for skill in &outcome.skills[skills_before_root..] { + if !skill_roots.contains(&root_path) { + skill_roots.push(root_path.clone()); + } + skill_root_by_path + .entry(skill.path_to_skills_md.clone()) + .or_insert_with(|| root_path.clone()); file_systems_by_skill_path .entry(skill.path_to_skills_md.clone()) .or_insert_with(|| Arc::clone(&fs)); @@ -181,7 +190,12 @@ where .iter() .map(|skill| skill.path_to_skills_md.clone()) .collect(); + skill_root_by_path.retain(|path, _| retained_skill_paths.contains(path)); + let used_roots: HashSet = skill_root_by_path.values().cloned().collect(); + skill_roots.retain(|root| used_roots.contains(root)); file_systems_by_skill_path.retain(|path, _| retained_skill_paths.contains(path)); + outcome.skill_roots = skill_roots; + outcome.skill_root_by_path = Arc::new(skill_root_by_path); outcome.file_systems_by_skill_path = SkillFileSystemsByPath::new(file_systems_by_skill_path); fn scope_rank(scope: SkillScope) -> u8 { diff --git a/codex-rs/core-skills/src/model.rs b/codex-rs/core-skills/src/model.rs index eb9a6f132f..0a72c24fe8 100644 --- a/codex-rs/core-skills/src/model.rs +++ b/codex-rs/core-skills/src/model.rs @@ -89,6 +89,8 @@ pub struct SkillLoadOutcome { pub skills: Vec, pub errors: Vec, pub disabled_paths: HashSet, + pub(crate) skill_roots: Vec, + pub(crate) skill_root_by_path: Arc>, pub(crate) file_systems_by_skill_path: SkillFileSystemsByPath, pub(crate) implicit_skills_by_scripts_dir: Arc>, pub(crate) implicit_skills_by_doc_path: Arc>, @@ -176,6 +178,19 @@ pub fn filter_skill_load_outcome_for_product( outcome .file_systems_by_skill_path .retain_paths(&retained_paths); + outcome.skill_root_by_path = Arc::new( + outcome + .skill_root_by_path + .iter() + .filter(|(path, _)| retained_paths.contains(*path)) + .map(|(path, root)| (path.clone(), root.clone())) + .collect(), + ); + let retained_roots: HashSet = + outcome.skill_root_by_path.values().cloned().collect(); + outcome + .skill_roots + .retain(|root| retained_roots.contains(root)); outcome.implicit_skills_by_scripts_dir = Arc::new( outcome .implicit_skills_by_scripts_dir diff --git a/codex-rs/core-skills/src/remote.rs b/codex-rs/core-skills/src/remote.rs index 2dc620b864..1ca7cd0cb7 100644 --- a/codex-rs/core-skills/src/remote.rs +++ b/codex-rs/core-skills/src/remote.rs @@ -48,11 +48,11 @@ fn as_query_product_surface(product_surface: RemoteSkillProductSurface) -> &'sta } } -fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth> { +fn ensure_codex_backend_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth> { let Some(auth) = auth else { anyhow::bail!("chatgpt authentication required for remote skill scopes"); }; - if !auth.is_chatgpt_auth() { + if !auth.uses_codex_backend() { anyhow::bail!( "chatgpt authentication required for remote skill scopes; api key auth is not supported" ); @@ -94,7 +94,7 @@ pub async fn list_remote_skills( enabled: Option, ) -> Result> { let base_url = chatgpt_base_url.trim_end_matches('/'); - let auth = ensure_chatgpt_auth(auth)?; + let auth = ensure_codex_backend_auth(auth)?; let url = format!("{base_url}/hazelnuts"); let product_surface = as_query_product_surface(product_surface); @@ -108,17 +108,11 @@ pub async fn list_remote_skills( } let client = build_reqwest_client(); - let mut request = client + let request = client .get(&url) .timeout(REMOTE_SKILLS_API_TIMEOUT) - .query(&query_params); - let token = auth - .get_token() - .context("Failed to read auth token for remote skills")?; - request = request.bearer_auth(token); - if let Some(account_id) = auth.get_account_id() { - request = request.header("chatgpt-account-id", account_id); - } + .query(&query_params) + .headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers()); let response = request .send() .await @@ -150,20 +144,15 @@ pub async fn export_remote_skill( auth: Option<&CodexAuth>, skill_id: &str, ) -> Result { - let auth = ensure_chatgpt_auth(auth)?; + let auth = ensure_codex_backend_auth(auth)?; let client = build_reqwest_client(); let base_url = chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/hazelnuts/{skill_id}/export"); - let mut request = client.get(&url).timeout(REMOTE_SKILLS_API_TIMEOUT); - - let token = auth - .get_token() - .context("Failed to read auth token for remote skills")?; - request = request.bearer_auth(token); - if let Some(account_id) = auth.get_account_id() { - request = request.header("chatgpt-account-id", account_id); - } + let request = client + .get(&url) + .timeout(REMOTE_SKILLS_API_TIMEOUT) + .headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers()); let response = request .send() diff --git a/codex-rs/core-skills/src/render.rs b/codex-rs/core-skills/src/render.rs index 6bcd5e4e05..002ee1b3a4 100644 --- a/codex-rs/core-skills/src/render.rs +++ b/codex-rs/core-skills/src/render.rs @@ -1,13 +1,86 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::Component; +use std::path::Path; + +use crate::model::SkillLoadOutcome; use crate::model::SkillMetadata; use codex_otel::SessionTelemetry; +use codex_otel::THREAD_SKILLS_DESCRIPTION_TRUNCATED_CHARS_METRIC; use codex_otel::THREAD_SKILLS_ENABLED_TOTAL_METRIC; use codex_otel::THREAD_SKILLS_KEPT_TOTAL_METRIC; use codex_otel::THREAD_SKILLS_TRUNCATED_METRIC; use codex_protocol::protocol::SkillScope; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::approx_token_count; const DEFAULT_SKILL_METADATA_CHAR_BUDGET: usize = 8_000; const SKILL_METADATA_CONTEXT_WINDOW_PERCENT: usize = 2; +const SKILL_DESCRIPTION_TRUNCATION_WARNING_THRESHOLD_CHARS: usize = 10; +const APPROX_BYTES_PER_TOKEN: usize = 4; +pub const SKILL_DESCRIPTION_TRUNCATED_WARNING_PREFIX: &str = "Warning: Exceeded skills context budget. Loaded skill descriptions were truncated by an average of"; +pub const SKILL_DESCRIPTIONS_REMOVED_WARNING_PREFIX: &str = + "Warning: Exceeded skills context budget. All skill descriptions were removed and"; +pub const SKILLS_INTRO_WITH_ABSOLUTE_PATHS: &str = "A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill."; +pub const SKILLS_INTRO_WITH_ALIASES: &str = "A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and a short path that can be expanded into an absolute path using the skill roots table."; +pub const SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS: &str = r###"- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths. +- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned. +- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback. +- How to use a skill (progressive disclosure): + 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow. + 2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed. + 3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything. + 4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks. + 5) If `assets/` or templates exist, reuse them instead of recreating from scratch. +- Coordination and sequencing: + - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them. + - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why. +- Context hygiene: + - Keep context small: summarize long sections instead of pasting them; only load extra files when needed. + - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked. + - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. +- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."###; +pub const SKILLS_HOW_TO_USE_WITH_ALIASES: &str = r###"- Discovery: The list above is the skills available in this session (name + description + short path). Skill bodies live on disk at the listed paths after expanding the matching alias from `### Skill roots`. +- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned. +- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback. +- How to use a skill (progressive disclosure): + 1) After deciding to use a skill, expand the listed short `path` with the matching alias from `### Skill roots`, then open its `SKILL.md`. Read only enough to follow the workflow. + 2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the directory containing that expanded `SKILL.md` first, and only consider other paths if needed. + 3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything. + 4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks. + 5) If `assets/` or templates exist, reuse them instead of recreating from scratch. +- Coordination and sequencing: + - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them. + - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why. +- Context hygiene: + - Keep context small: summarize long sections instead of pasting them; only load extra files when needed. + - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked. + - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. +- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."###; + +pub fn render_available_skills_body(skill_root_lines: &[String], skill_lines: &[String]) -> String { + let mut lines: Vec = Vec::new(); + lines.push("## Skills".to_string()); + if skill_root_lines.is_empty() { + lines.push(SKILLS_INTRO_WITH_ABSOLUTE_PATHS.to_string()); + } else { + lines.push(SKILLS_INTRO_WITH_ALIASES.to_string()); + lines.push("### Skill roots".to_string()); + lines.extend(skill_root_lines.iter().cloned()); + } + lines.push("### Available skills".to_string()); + lines.extend(skill_lines.iter().cloned()); + + lines.push("### How to use skills".to_string()); + let how_to_use = if skill_root_lines.is_empty() { + SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS + } else { + SKILLS_HOW_TO_USE_WITH_ALIASES + }; + lines.push(how_to_use.to_string()); + + format!("\n{}\n", lines.join("\n")) +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SkillMetadataBudget { @@ -28,6 +101,17 @@ impl SkillMetadataBudget { Self::Characters(_) => text.chars().count(), } } + + fn cost_from_counts(self, chars: usize, bytes: usize) -> usize { + match self { + Self::Tokens(_) => approx_token_count_from_bytes(bytes), + Self::Characters(_) => chars, + } + } +} + +fn approx_token_count_from_bytes(bytes: usize) -> usize { + bytes.saturating_add(APPROX_BYTES_PER_TOKEN.saturating_sub(1)) / APPROX_BYTES_PER_TOKEN } #[derive(Debug, Clone, PartialEq, Eq)] @@ -35,6 +119,8 @@ pub struct SkillRenderReport { pub total_count: usize, pub included_count: usize, pub omitted_count: usize, + pub truncated_description_chars: usize, + pub truncated_description_count: usize, } #[derive(Clone, Copy)] @@ -47,9 +133,10 @@ pub enum SkillRenderSideEffects<'a> { #[derive(Debug, Clone, PartialEq, Eq)] pub struct AvailableSkills { + pub skill_root_lines: Vec, pub skill_lines: Vec, pub report: SkillRenderReport, - pub emit_warning: bool, + pub warning_message: Option, } pub fn default_skill_metadata_budget(context_window: Option) -> SkillMetadataBudget { @@ -70,42 +157,142 @@ pub fn default_skill_metadata_budget(context_window: Option) -> SkillMetada } pub fn build_available_skills( - skills: &[SkillMetadata], + outcome: &SkillLoadOutcome, budget: SkillMetadataBudget, side_effects: SkillRenderSideEffects<'_>, ) -> Option { + let skills = outcome.allowed_skills_for_implicit_invocation(); if skills.is_empty() { - let _ = record_skill_render_side_effects( + record_skill_render_side_effects( side_effects, /*total_count*/ 0, /*included_count*/ 0, - /*truncated*/ false, + /*omitted_count*/ 0, + /*truncated_description_chars*/ 0, ); return None; } - let (skill_lines, report) = render_skill_lines(skills, budget); - let emit_warning = record_skill_render_side_effects( - side_effects, - report.total_count, - report.included_count, - report.omitted_count > 0, - ); - Some(AvailableSkills { + let absolute_lines = ordered_absolute_skill_lines(&skills); + let absolute = build_available_skills_from_lines( + absolute_lines, + skills.len(), + budget, + SkillPathAliases::default(), + )?; + + let selected = + if absolute.report.omitted_count == 0 && absolute.report.truncated_description_chars == 0 { + absolute + } else if let Some(aliased) = build_aliased_available_skills(outcome, &skills, budget) { + if aliased_render_is_better(&aliased, &absolute, budget) { + aliased + } else { + absolute + } + } else { + absolute + }; + + record_available_skills_side_effects(&selected, budget, side_effects); + Some(selected) +} + +fn build_available_skills_from_lines( + skill_lines: Vec>, + total_count: usize, + budget: SkillMetadataBudget, + path_aliases: SkillPathAliases, +) -> Option { + if total_count == 0 { + return None; + } + + let (skill_lines, report) = render_skill_lines_from_lines(skill_lines, total_count, budget); + let warning_message = if report.omitted_count > 0 { + let skill_word = if report.omitted_count == 1 { + "skill" + } else { + "skills" + }; + let verb = if report.omitted_count == 1 { + "was" + } else { + "were" + }; + Some(format!( + "{} {} additional {} {} not included in the model-visible skills list.", + budget_warning_prefix(budget, SKILL_DESCRIPTIONS_REMOVED_WARNING_PREFIX), + report.omitted_count, + skill_word, + verb + )) + } else if report.average_truncated_description_chars() + > SKILL_DESCRIPTION_TRUNCATION_WARNING_THRESHOLD_CHARS + { + Some(format!( + "{} {} characters per skill.", + budget_warning_prefix(budget, SKILL_DESCRIPTION_TRUNCATED_WARNING_PREFIX), + report.average_truncated_description_chars() + )) + } else { + None + }; + let available = AvailableSkills { + skill_root_lines: path_aliases.skill_root_lines, skill_lines, report, - emit_warning, - }) + warning_message, + }; + Some(available) +} + +fn record_available_skills_side_effects( + available: &AvailableSkills, + budget: SkillMetadataBudget, + side_effects: SkillRenderSideEffects<'_>, +) { + record_skill_render_side_effects( + side_effects, + available.report.total_count, + available.report.included_count, + available.report.omitted_count, + available.report.truncated_description_chars, + ); + if available.report.omitted_count > 0 || available.report.truncated_description_chars > 0 { + tracing::info!( + budget_limit = budget.limit(), + total_skills = available.report.total_count, + included_skills = available.report.included_count, + omitted_skills = available.report.omitted_count, + truncated_description_chars_per_skill = + available.report.average_truncated_description_chars(), + truncated_skill_descriptions = available.report.truncated_description_count, + "truncated skill metadata to fit skills context budget" + ); + } +} + +fn budget_warning_prefix(budget: SkillMetadataBudget, prefix: &str) -> String { + match budget { + SkillMetadataBudget::Tokens(_) => prefix.replacen( + "Exceeded skills context budget.", + "Exceeded skills context budget of 2%.", + 1, + ), + SkillMetadataBudget::Characters(_) => prefix.to_string(), + } } fn record_skill_render_side_effects( side_effects: SkillRenderSideEffects<'_>, total_count: usize, included_count: usize, - truncated: bool, -) -> bool { + omitted_count: usize, + truncated_description_chars: usize, +) { match side_effects { - SkillRenderSideEffects::None => false, + SkillRenderSideEffects::None => {} SkillRenderSideEffects::ThreadStart { session_telemetry } => { session_telemetry.histogram( THREAD_SKILLS_ENABLED_TOTAL_METRIC, @@ -119,45 +306,579 @@ fn record_skill_render_side_effects( ); session_telemetry.histogram( THREAD_SKILLS_TRUNCATED_METRIC, - if truncated { 1 } else { 0 }, + if omitted_count > 0 { 1 } else { 0 }, + &[], + ); + session_telemetry.histogram( + THREAD_SKILLS_DESCRIPTION_TRUNCATED_CHARS_METRIC, + i64::try_from(truncated_description_chars).unwrap_or(i64::MAX), &[], ); - truncated } } } -fn render_skill_lines( - skills: &[SkillMetadata], +fn render_skill_lines_from_lines( + skill_lines: Vec>, + total_count: usize, budget: SkillMetadataBudget, ) -> (Vec, SkillRenderReport) { - let ordered_skills = ordered_skills_for_budget(skills); + let full_cost = skill_lines.iter().fold(0usize, |used, line| { + used.saturating_add(line.full_cost(budget)) + }); + if full_cost <= budget.limit() { + let included = skill_lines + .iter() + .map(SkillLine::render_full) + .collect::>(); + return ( + included, + skill_render_report( + total_count, + /*included_count*/ skill_lines.len(), + /*omitted_count*/ 0, + /*truncated_description_chars*/ 0, + /*truncated_description_count*/ 0, + ), + ); + } + + let minimum_cost = skill_lines.iter().fold(0usize, |used, line| { + used.saturating_add(line.minimum_cost(budget)) + }); + if minimum_cost <= budget.limit() { + let rendered = render_lines_with_description_budget( + budget, + &skill_lines, + budget.limit().saturating_sub(minimum_cost), + ); + let (truncated_description_chars, truncated_description_count) = + sum_description_truncation(&rendered); + let included = rendered + .into_iter() + .map(|rendered| rendered.line) + .collect::>(); + + return ( + included, + skill_render_report( + total_count, + /*included_count*/ skill_lines.len(), + /*omitted_count*/ 0, + truncated_description_chars, + truncated_description_count, + ), + ); + } + + render_minimum_skill_lines_until_budget(budget, skill_lines, total_count) +} + +fn render_minimum_skill_lines_until_budget( + budget: SkillMetadataBudget, + skill_lines: Vec>, + total_count: usize, +) -> (Vec, SkillRenderReport) { let mut included = Vec::new(); let mut used = 0usize; let mut omitted_count = 0usize; - - for skill in ordered_skills { - let line = render_skill_line(skill); - let line_cost = budget.cost(&format!("{line}\n")); + let mut truncated_description_chars = 0usize; + let mut truncated_description_count = 0usize; + for line in skill_lines { + let line_cost = line.minimum_cost(budget); + let description_char_count = line.description_char_count(); if used.saturating_add(line_cost) <= budget.limit() { used = used.saturating_add(line_cost); - included.push(line); - continue; + included.push(line.render_minimum()); + } else { + omitted_count = omitted_count.saturating_add(1); } - omitted_count = omitted_count.saturating_add(1); + truncated_description_chars = + truncated_description_chars.saturating_add(description_char_count); + if description_char_count > 0 { + truncated_description_count = truncated_description_count.saturating_add(1); + } } - let report = SkillRenderReport { - total_count: skills.len(), - included_count: included.len(), + let report = skill_render_report( + total_count, + included.len(), omitted_count, - }; + truncated_description_chars, + truncated_description_count, + ); (included, report) } +fn skill_render_report( + total_count: usize, + included_count: usize, + omitted_count: usize, + truncated_description_chars: usize, + truncated_description_count: usize, +) -> SkillRenderReport { + SkillRenderReport { + total_count, + included_count, + omitted_count, + truncated_description_chars, + truncated_description_count, + } +} + +impl SkillRenderReport { + fn average_truncated_description_chars(&self) -> usize { + if self.truncated_description_count == 0 { + return 0; + } + + self.truncated_description_chars + .saturating_add(self.truncated_description_count.saturating_sub(1)) + / self.truncated_description_count + } +} + +struct SkillLine<'a> { + name: &'a str, + description: &'a str, + path: String, +} + +struct RenderedSkillLine { + line: String, + truncated_chars: usize, +} + +struct DescriptionBudgetLine<'a> { + line: &'a SkillLine<'a>, + description_char_count: usize, + extra_costs: Vec, +} + +fn sum_description_truncation(rendered: &[RenderedSkillLine]) -> (usize, usize) { + rendered + .iter() + .fold((0usize, 0usize), |(chars, count), line| { + if line.truncated_chars == 0 { + (chars, count) + } else { + ( + chars.saturating_add(line.truncated_chars), + count.saturating_add(1), + ) + } + }) +} + +impl<'a> SkillLine<'a> { + fn new(skill: &'a SkillMetadata) -> Self { + Self::with_path( + skill, + skill.path_to_skills_md.to_string_lossy().replace('\\', "/"), + ) + } + + fn with_path(skill: &'a SkillMetadata, path: String) -> Self { + Self { + name: skill.name.as_str(), + description: skill.description.as_str(), + path, + } + } + + fn full_cost(&self, budget: SkillMetadataBudget) -> usize { + line_cost(budget, &self.render_full()) + } + + fn minimum_cost(&self, budget: SkillMetadataBudget) -> usize { + line_cost(budget, &self.render_minimum()) + } + + fn description_char_count(&self) -> usize { + self.description.chars().count() + } + + fn render_full(&self) -> String { + self.render_with_description(self.description) + } + + fn render_minimum(&self) -> String { + self.render_with_description("") + } + + fn rendered_description_prefix_len(&self, description_chars: usize) -> usize { + self.description + .char_indices() + .nth(description_chars) + .map_or(self.description.len(), |(idx, _)| idx) + } + + fn render_with_description_chars(&self, description_chars: usize) -> String { + if description_chars == 0 { + format!("- {}: (file: {})", self.name, self.path) + } else { + let end = self.rendered_description_prefix_len(description_chars); + let description = &self.description[..end]; + format!("- {}: {} (file: {})", self.name, description, self.path) + } + } + + fn render_with_description(&self, description: &str) -> String { + if description.is_empty() { + format!("- {}: (file: {})", self.name, self.path) + } else { + format!("- {}: {} (file: {})", self.name, description, self.path) + } + } +} + +impl<'a> DescriptionBudgetLine<'a> { + fn new(line: &'a SkillLine<'a>, budget: SkillMetadataBudget) -> Self { + let minimum_line = line.render_minimum(); + let minimum_chars = minimum_line.chars().count().saturating_add(1); + let minimum_bytes = minimum_line.len().saturating_add(1); + let minimum_cost = budget.cost_from_counts(minimum_chars, minimum_bytes); + + let description_char_count = line.description_char_count(); + let mut extra_costs = Vec::with_capacity(description_char_count.saturating_add(1)); + extra_costs.push(0); + + let mut prefix_chars = 0usize; + let mut prefix_bytes = 0usize; + for ch in line.description.chars() { + prefix_chars = prefix_chars.saturating_add(1); + prefix_bytes = prefix_bytes.saturating_add(ch.len_utf8()); + let rendered_chars = minimum_chars.saturating_add(prefix_chars).saturating_add(1); + let rendered_bytes = minimum_bytes.saturating_add(prefix_bytes).saturating_add(1); + let cost = budget + .cost_from_counts(rendered_chars, rendered_bytes) + .saturating_sub(minimum_cost); + extra_costs.push(cost); + } + + Self { + line, + description_char_count, + extra_costs, + } + } +} + +fn line_cost(budget: SkillMetadataBudget, line: &str) -> usize { + budget.cost(&format!("{line}\n")) +} + +fn lines_cost(budget: SkillMetadataBudget, lines: &[String]) -> usize { + lines.iter().fold(0usize, |used, line| { + used.saturating_add(line_cost(budget, line)) + }) +} + +fn render_lines_with_description_budget( + budget: SkillMetadataBudget, + skill_lines: &[SkillLine<'_>], + limit: usize, +) -> Vec { + let budget_lines = skill_lines + .iter() + .map(|line| DescriptionBudgetLine::new(line, budget)) + .collect::>(); + let mut char_allocations = vec![0usize; budget_lines.len()]; + let mut current_extra_costs = vec![0usize; budget_lines.len()]; + let mut remaining = limit; + + // Distribute description space one character at a time across skills. + // Short descriptions naturally drop out, so their unused share can go to + // longer descriptions instead of being stranded in a fixed per-skill quota. + loop { + let mut changed = false; + for (index, line) in budget_lines.iter().enumerate() { + if char_allocations[index] >= line.description_char_count { + continue; + } + + let current_cost = current_extra_costs[index]; + let next_chars = char_allocations[index].saturating_add(1); + let next_cost = line.extra_costs[next_chars]; + let delta = next_cost.saturating_sub(current_cost); + if delta <= remaining { + char_allocations[index] = next_chars; + current_extra_costs[index] = next_cost; + remaining = remaining.saturating_sub(delta); + changed = true; + } + } + + if !changed { + break; + } + } + + budget_lines + .iter() + .zip(char_allocations) + .map(|(line, description_chars)| { + let truncated_chars = line + .description_char_count + .saturating_sub(description_chars); + RenderedSkillLine { + line: line.line.render_with_description_chars(description_chars), + truncated_chars, + } + }) + .collect() +} + +fn build_aliased_available_skills( + outcome: &SkillLoadOutcome, + skills: &[SkillMetadata], + budget: SkillMetadataBudget, +) -> Option { + let plan = build_alias_plan(outcome, skills, budget)?; + if plan.table_cost >= budget.limit() { + return None; + } + + let adjusted_limit = budget.limit().saturating_sub(plan.table_cost); + let adjusted_budget = match budget { + SkillMetadataBudget::Tokens(_) => SkillMetadataBudget::Tokens(adjusted_limit), + SkillMetadataBudget::Characters(_) => SkillMetadataBudget::Characters(adjusted_limit), + }; + let ordered_skills = ordered_skills_for_budget(skills); + let skill_lines = ordered_skills + .into_iter() + .map(|skill| SkillLine::with_path(skill, render_skill_path_with_aliases(skill, &plan))) + .collect::>(); + build_available_skills_from_lines(skill_lines, skills.len(), adjusted_budget, plan.aliases) +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct SkillPathAliases { + skill_root_lines: Vec, +} + +struct AliasPlan { + aliases: SkillPathAliases, + root_aliases: HashMap, + alias_root_by_path: HashMap, + table_cost: usize, +} + +fn build_alias_plan( + outcome: &SkillLoadOutcome, + skills: &[SkillMetadata], + budget: SkillMetadataBudget, +) -> Option { + let skill_paths = skills + .iter() + .map(|skill| skill.path_to_skills_md.clone()) + .collect::>(); + let skill_root_by_path = outcome + .skill_root_by_path + .iter() + .filter(|(path, _)| skill_paths.contains(*path)) + .map(|(path, root)| (path.clone(), root.clone())) + .collect::>(); + let used_roots = outcome + .skill_roots + .iter() + .filter(|root| { + skill_root_by_path + .values() + .any(|skill_root| skill_root == *root) + }) + .cloned() + .collect::>(); + if used_roots.is_empty() { + return None; + } + + let plugin_version_skill_counts = + plugin_version_skill_counts_for_skill_roots(skill_root_by_path.values()); + let alias_root_by_skill_root = used_roots + .iter() + .map(|root| { + ( + root.clone(), + alias_root_for_skill_root(root, &plugin_version_skill_counts), + ) + }) + .collect::>(); + let alias_roots = ordered_alias_roots(&used_roots, &alias_root_by_skill_root)?; + let root_aliases = alias_roots + .iter() + .enumerate() + .map(|(index, alias_root)| (alias_root.clone(), format!("r{index}"))) + .collect::>(); + let alias_root_by_path = skill_root_by_path + .iter() + .filter_map(|(path, skill_root)| { + alias_root_by_skill_root + .get(skill_root) + .map(|alias_root| (path.clone(), alias_root.clone())) + }) + .collect::>(); + let skill_root_lines = build_skill_root_lines(&alias_roots); + let table_cost = aliased_metadata_overhead_cost(budget, &skill_root_lines); + + Some(AliasPlan { + aliases: SkillPathAliases { skill_root_lines }, + root_aliases, + alias_root_by_path, + table_cost, + }) +} + +fn ordered_alias_roots( + used_roots: &[AbsolutePathBuf], + alias_root_by_skill_root: &HashMap, +) -> Option> { + let mut seen = HashSet::new(); + let mut alias_roots = Vec::new(); + for root in used_roots { + let alias_root = alias_root_by_skill_root.get(root)?.clone(); + if seen.insert(alias_root.clone()) { + alias_roots.push(alias_root); + } + } + Some(alias_roots) +} + +fn alias_root_for_skill_root( + root: &AbsolutePathBuf, + plugin_version_skill_counts: &HashMap, +) -> AbsolutePathBuf { + let Some(plugin_version_base) = plugin_version_base(root.as_path()) else { + return root.clone(); + }; + let skill_count = plugin_version_skill_counts + .get(&plugin_version_base) + .copied() + .unwrap_or_default(); + if skill_count > 1 { + root.clone() + } else { + plugin_marketplace_base(root.as_path()).unwrap_or_else(|| root.clone()) + } +} + +fn plugin_version_skill_counts_for_skill_roots<'a>( + skill_roots: impl Iterator, +) -> HashMap { + let mut counts = HashMap::new(); + for root in skill_roots { + if let Some(plugin_version_base) = plugin_version_base(root.as_path()) { + let count = counts.entry(plugin_version_base).or_insert(0usize); + *count = count.saturating_add(1); + } + } + counts +} + +fn aliased_metadata_overhead_cost( + budget: SkillMetadataBudget, + skill_root_lines: &[String], +) -> usize { + let empty_skill_lines: &[String] = &[]; + let absolute_body = render_available_skills_body(&[], empty_skill_lines); + let aliased_body = render_available_skills_body(skill_root_lines, empty_skill_lines); + budget + .cost(&aliased_body) + .saturating_sub(budget.cost(&absolute_body)) +} + +fn build_skill_root_lines(roots: &[AbsolutePathBuf]) -> Vec { + roots + .iter() + .enumerate() + .map(|(index, root)| { + let root_str = root.to_string_lossy().replace('\\', "/"); + format!("- `r{index}` = `{root_str}`") + }) + .collect() +} + +fn plugin_marketplace_base(path: &Path) -> Option { + let mut candidate = path; + while let Some(parent) = candidate.parent() { + if parent.file_name()?.to_str()? == "cache" + && parent.parent()?.file_name()?.to_str()? == "plugins" + { + return AbsolutePathBuf::from_absolute_path(candidate).ok(); + } + candidate = parent; + } + None +} + +fn plugin_version_base(path: &Path) -> Option { + let marketplace_base = plugin_marketplace_base(path)?; + let mut relative_components = path + .strip_prefix(marketplace_base.as_path()) + .ok()? + .components(); + let plugin = match relative_components.next()? { + Component::Normal(plugin) => plugin, + _ => return None, + }; + let version = match relative_components.next()? { + Component::Normal(version) => version, + _ => return None, + }; + AbsolutePathBuf::from_absolute_path(marketplace_base.join(plugin).join(version)).ok() +} + +fn render_skill_path_with_aliases(skill: &SkillMetadata, plan: &AliasPlan) -> String { + outcome_relative_skill_path(skill, plan) + .unwrap_or_else(|| skill.path_to_skills_md.to_string_lossy().replace('\\', "/")) +} + +fn outcome_relative_skill_path(skill: &SkillMetadata, plan: &AliasPlan) -> Option { + let alias_root = plan.alias_root_by_path.get(&skill.path_to_skills_md)?; + let alias = plan.root_aliases.get(alias_root)?; + let relative_path = skill + .path_to_skills_md + .as_path() + .strip_prefix(alias_root.as_path()) + .ok()?; + let relative_path = relative_path.to_string_lossy().replace('\\', "/"); + Some(format!("{alias}/{relative_path}")) +} + +fn aliased_render_is_better( + aliased: &AvailableSkills, + absolute: &AvailableSkills, + budget: SkillMetadataBudget, +) -> bool { + if aliased.report.included_count != absolute.report.included_count { + return aliased.report.included_count > absolute.report.included_count; + } + if aliased.report.truncated_description_chars != absolute.report.truncated_description_chars { + return aliased.report.truncated_description_chars + < absolute.report.truncated_description_chars; + } + available_skills_cost(budget, aliased) < available_skills_cost(budget, absolute) +} + +fn available_skills_cost(budget: SkillMetadataBudget, available: &AvailableSkills) -> usize { + let metadata_cost = if available.skill_root_lines.is_empty() { + 0 + } else { + aliased_metadata_overhead_cost(budget, &available.skill_root_lines) + }; + metadata_cost.saturating_add(lines_cost(budget, &available.skill_lines)) +} + +fn ordered_absolute_skill_lines(skills: &[SkillMetadata]) -> Vec> { + ordered_skills_for_budget(skills) + .into_iter() + .map(SkillLine::new) + .collect() +} + fn ordered_skills_for_budget(skills: &[SkillMetadata]) -> Vec<&SkillMetadata> { let mut ordered = skills.iter().collect::>(); ordered.sort_by(|a, b| { @@ -178,16 +899,12 @@ fn prompt_scope_rank(scope: SkillScope) -> u8 { } } -fn render_skill_line(skill: &SkillMetadata) -> String { - let path_str = skill.path_to_skills_md.to_string_lossy().replace('\\', "/"); - let name = skill.name.as_str(); - let description = skill.description.as_str(); - format!("- {name}: {description} (file: {path_str})") -} - #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; + use std::sync::Arc; + use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; @@ -205,6 +922,62 @@ mod tests { } } + fn make_skill_with_description( + name: &str, + scope: SkillScope, + description: &str, + ) -> SkillMetadata { + let mut skill = make_skill(name, scope); + skill.description = description.to_string(); + skill + } + + fn expected_skill_line(skill: &SkillMetadata, description: &str) -> String { + SkillLine::new(skill).render_with_description(description) + } + + fn normalized_path(path: &AbsolutePathBuf) -> String { + path.to_string_lossy().replace('\\', "/") + } + + fn outcome_with_roots( + skills: Vec, + roots: Vec, + ) -> SkillLoadOutcome { + let skill_root_by_path = skills + .iter() + .filter_map(|skill| { + roots + .iter() + .find(|root| { + skill + .path_to_skills_md + .as_path() + .starts_with(root.as_path()) + }) + .map(|root| (skill.path_to_skills_md.clone(), root.clone())) + }) + .collect::>(); + SkillLoadOutcome { + skills, + skill_roots: roots, + skill_root_by_path: Arc::new(skill_root_by_path), + ..Default::default() + } + } + + fn build_available_skills_from_metadata( + skills: &[SkillMetadata], + budget: SkillMetadataBudget, + ) -> Option { + build_available_skills_from_lines( + ordered_absolute_skill_lines(skills), + skills.len(), + budget, + SkillPathAliases::default(), + ) + } + #[test] fn default_budget_uses_two_percent_of_full_context_window() { assert_eq!( @@ -230,30 +1003,127 @@ mod tests { } #[test] - fn budgeted_rendering_preserves_prompt_priority() { + fn budgeted_rendering_truncates_descriptions_equally_before_omitting_skills() { + let alpha = make_skill_with_description("alpha-skill", SkillScope::Repo, "abcdef"); + let beta = make_skill_with_description("beta-skill", SkillScope::Repo, "uvwxyz"); + let minimum_cost = SkillLine::new(&alpha) + .minimum_cost(SkillMetadataBudget::Characters(usize::MAX)) + + SkillLine::new(&beta).minimum_cost(SkillMetadataBudget::Characters(usize::MAX)); + let budget = SkillMetadataBudget::Characters(minimum_cost + 6); + + let rendered = build_available_skills_from_metadata(&[beta.clone(), alpha.clone()], budget) + .expect("skills should render"); + + assert_eq!(rendered.report.included_count, 2); + assert_eq!(rendered.report.omitted_count, 0); + assert_eq!(rendered.report.truncated_description_chars, 8); + assert_eq!(rendered.warning_message, None); + assert_eq!( + rendered.skill_lines, + vec![ + expected_skill_line(&alpha, "ab"), + expected_skill_line(&beta, "uv"), + ] + ); + } + + #[test] + fn budgeted_rendering_does_not_warn_when_average_description_truncation_is_within_threshold() { + let alpha = make_skill_with_description("alpha-skill", SkillScope::Repo, "abcdefghij"); + let beta = make_skill_with_description("beta-skill", SkillScope::Repo, "uvwxyzabcd"); + let minimum_cost = SkillLine::new(&alpha) + .minimum_cost(SkillMetadataBudget::Characters(usize::MAX)) + + SkillLine::new(&beta).minimum_cost(SkillMetadataBudget::Characters(usize::MAX)); + let budget = SkillMetadataBudget::Characters(minimum_cost + 6); + + let rendered = build_available_skills_from_metadata(&[alpha, beta], budget) + .expect("skills should render"); + + assert_eq!(rendered.report.included_count, 2); + assert_eq!(rendered.report.omitted_count, 0); + assert_eq!(rendered.report.truncated_description_chars, 16); + assert_eq!(rendered.report.truncated_description_count, 2); + assert_eq!(rendered.warning_message, None); + } + + #[test] + fn budgeted_rendering_warns_when_average_description_truncation_exceeds_threshold() { + let alpha = + make_skill_with_description("alpha-skill", SkillScope::Repo, "abcdefghijklmnop"); + let beta = make_skill_with_description("beta-skill", SkillScope::Repo, "uvwxyzabcdefghij"); + let minimum_cost = SkillLine::new(&alpha) + .minimum_cost(SkillMetadataBudget::Characters(usize::MAX)) + + SkillLine::new(&beta).minimum_cost(SkillMetadataBudget::Characters(usize::MAX)); + let budget = SkillMetadataBudget::Characters(minimum_cost + 6); + + let rendered = build_available_skills_from_metadata(&[alpha, beta], budget) + .expect("skills should render"); + + assert_eq!(rendered.report.included_count, 2); + assert_eq!(rendered.report.omitted_count, 0); + assert_eq!(rendered.report.truncated_description_chars, 28); + assert_eq!(rendered.report.truncated_description_count, 2); + assert_eq!( + rendered.warning_message, + Some( + "Warning: Exceeded skills context budget. Loaded skill descriptions were truncated by an average of 14 characters per skill." + .to_string() + ) + ); + } + + #[test] + fn budgeted_rendering_redistributes_unused_description_budget() { + let short = make_skill_with_description("short-skill", SkillScope::Repo, "x"); + let long = make_skill_with_description("long-skill", SkillScope::Repo, "abcdefghi"); + let minimum_cost = SkillLine::new(&short) + .minimum_cost(SkillMetadataBudget::Characters(usize::MAX)) + + SkillLine::new(&long).minimum_cost(SkillMetadataBudget::Characters(usize::MAX)); + let budget = SkillMetadataBudget::Characters(minimum_cost + 11); + + let rendered = build_available_skills_from_metadata(&[short.clone(), long.clone()], budget) + .expect("skills should render"); + + assert_eq!(rendered.report.included_count, 2); + assert_eq!(rendered.report.omitted_count, 0); + assert_eq!(rendered.warning_message, None); + assert_eq!( + rendered.skill_lines, + vec![ + expected_skill_line(&long, "abcdefgh"), + expected_skill_line(&short, "x"), + ] + ); + } + + #[test] + fn budgeted_rendering_preserves_prompt_priority_when_minimum_lines_exceed_budget() { let system = make_skill("system-skill", SkillScope::System); let user = make_skill("user-skill", SkillScope::User); let repo = make_skill("repo-skill", SkillScope::Repo); let admin = make_skill("admin-skill", SkillScope::Admin); let system_cost = SkillMetadataBudget::Characters(usize::MAX) - .cost(&format!("{}\n", render_skill_line(&system))); + .cost(&format!("{}\n", SkillLine::new(&system).render_minimum())); let admin_cost = SkillMetadataBudget::Characters(usize::MAX) - .cost(&format!("{}\n", render_skill_line(&admin))); + .cost(&format!("{}\n", SkillLine::new(&admin).render_minimum())); let budget = SkillMetadataBudget::Characters(system_cost + admin_cost); - let rendered = build_available_skills( - &[system, user, repo, admin], - budget, - SkillRenderSideEffects::None, - ) - .expect("skills should render"); + let rendered = build_available_skills_from_metadata(&[system, user, repo, admin], budget) + .expect("skills should render"); assert_eq!(rendered.report.included_count, 2); assert_eq!(rendered.report.omitted_count, 2); - assert!(!rendered.emit_warning); + assert_eq!( + rendered.warning_message, + Some( + "Warning: Exceeded skills context budget. All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list." + .to_string() + ) + ); let rendered_text = rendered.skill_lines.join("\n"); assert!(rendered_text.contains("- system-skill:")); assert!(rendered_text.contains("- admin-skill:")); + assert!(!rendered_text.contains("desc")); assert!(!rendered_text.contains("- repo-skill:")); assert!(!rendered_text.contains("- user-skill:")); } @@ -264,18 +1134,354 @@ mod tests { oversized.description = "desc ".repeat(100); let repo = make_skill("repo-skill", SkillScope::Repo); let repo_cost = SkillMetadataBudget::Characters(usize::MAX) - .cost(&format!("{}\n", render_skill_line(&repo))); + .cost(&format!("{}\n", SkillLine::new(&repo).render_full())); let budget = SkillMetadataBudget::Characters(repo_cost); - let rendered = - build_available_skills(&[oversized, repo], budget, SkillRenderSideEffects::None) - .expect("skills render"); + let rendered = build_available_skills_from_metadata(&[oversized, repo], budget) + .expect("skills render"); assert_eq!(rendered.report.included_count, 1); assert_eq!(rendered.report.omitted_count, 1); - assert!(!rendered.emit_warning); + assert_eq!( + rendered.warning_message, + Some( + "Warning: Exceeded skills context budget. All skill descriptions were removed and 1 additional skill was not included in the model-visible skills list." + .to_string() + ) + ); let rendered_text = rendered.skill_lines.join("\n"); assert!(!rendered_text.contains("- oversized-system-skill:")); assert!(rendered_text.contains("- repo-skill:")); } + + #[test] + fn outcome_rendering_omits_aliases_when_absolute_plan_has_no_budget_pressure() { + let root = test_path_buf("/tmp/skills").abs(); + let alpha_path = root.join("alpha/SKILL.md"); + let beta_path = root.join("beta/SKILL.md"); + let outcome = outcome_with_roots( + vec![ + skill_with_path("alpha-skill", &alpha_path), + skill_with_path("beta-skill", &beta_path), + ], + vec![root], + ); + + let rendered = build_available_skills( + &outcome, + SkillMetadataBudget::Characters(usize::MAX), + SkillRenderSideEffects::None, + ) + .expect("skills should render"); + + assert!(rendered.skill_root_lines.is_empty()); + assert_eq!(rendered.report.included_count, 2); + } + + #[test] + fn outcome_rendering_uses_aliases_when_they_allow_more_skills_to_fit() { + let root = test_path_buf( + "/Users/xl/.codex/plugins/cache/openai-curated/example/hash1234567890/skills-with-a-very-long-shared-prefix", + ) + .abs(); + let skills = (0..12) + .map(|index| { + let name = format!("shared-root-skill-{index}"); + skill_with_path(&name, &root.join(format!("skill-{index}/SKILL.md"))) + }) + .collect::>(); + let outcome = outcome_with_roots(skills.clone(), vec![root]); + let absolute_minimum = skills.iter().fold(0usize, |cost, skill| { + cost.saturating_add( + SkillLine::new(skill).minimum_cost(SkillMetadataBudget::Characters(usize::MAX)), + ) + }); + let plan = build_alias_plan( + &outcome, + &skills, + SkillMetadataBudget::Characters(usize::MAX), + ) + .expect("alias plan should build"); + let alias_minimum = skills.iter().fold(plan.table_cost, |cost, skill| { + cost.saturating_add( + SkillLine::with_path(skill, render_skill_path_with_aliases(skill, &plan)) + .minimum_cost(SkillMetadataBudget::Characters(usize::MAX)), + ) + }); + assert!( + alias_minimum < absolute_minimum, + "test fixture should make aliases cheaper" + ); + + let rendered = build_available_skills( + &outcome, + SkillMetadataBudget::Characters(alias_minimum), + SkillRenderSideEffects::None, + ) + .expect("skills should render"); + + assert_eq!(rendered.report.included_count, skills.len()); + assert_eq!(rendered.report.omitted_count, 0); + assert_eq!( + rendered.skill_root_lines, + vec![format!( + "- `r0` = `{}`", + normalized_path( + &test_path_buf( + "/Users/xl/.codex/plugins/cache/openai-curated/example/hash1234567890/skills-with-a-very-long-shared-prefix" + ) + .abs() + ) + )] + ); + let rendered_text = rendered.skill_lines.join("\n"); + assert!(rendered_text.contains("r0/skill-0/SKILL.md")); + assert!(rendered_text.contains("r0/skill-11/SKILL.md")); + } + + #[test] + fn outcome_rendering_uses_marketplace_root_for_single_skill_plugin_versions() { + let github_root = + test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated/github/hash123/skills") + .abs(); + let marketplace_root = test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated").abs(); + let github = skill_with_path("github:gh-fix-ci", &github_root.join("gh-fix-ci/SKILL.md")); + let outcome = outcome_with_roots(vec![github.clone()], vec![github_root.clone()]); + let plan = build_alias_plan( + &outcome, + &[github], + SkillMetadataBudget::Characters(usize::MAX), + ) + .expect("alias plan should build"); + + assert_eq!( + plan.aliases.skill_root_lines, + vec![format!("- `r0` = `{}`", normalized_path(&marketplace_root))] + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path("github:gh-fix-ci", &github_root.join("gh-fix-ci/SKILL.md")), + &plan + ), + "r0/github/hash123/skills/gh-fix-ci/SKILL.md" + ); + } + + #[test] + fn outcome_rendering_uses_skill_root_for_multiple_skills_in_one_plugin_version() { + let github_root = + test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated/github/hash123/skills") + .abs(); + let fix_ci = skill_with_path("github:gh-fix-ci", &github_root.join("gh-fix-ci/SKILL.md")); + let yeet = skill_with_path("github:yeet", &github_root.join("yeet/SKILL.md")); + let outcome = outcome_with_roots( + vec![fix_ci.clone(), yeet.clone()], + vec![github_root.clone()], + ); + let plan = build_alias_plan( + &outcome, + &[fix_ci, yeet], + SkillMetadataBudget::Characters(usize::MAX), + ) + .expect("alias plan should build"); + + assert_eq!( + plan.aliases.skill_root_lines, + vec![format!("- `r0` = `{}`", normalized_path(&github_root))] + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path("github:gh-fix-ci", &github_root.join("gh-fix-ci/SKILL.md")), + &plan + ), + "r0/gh-fix-ci/SKILL.md" + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path("github:yeet", &github_root.join("yeet/SKILL.md")), + &plan + ), + "r0/yeet/SKILL.md" + ); + } + + #[test] + fn outcome_rendering_counts_plugin_version_skills_before_budget_omission() { + let root = test_path_buf( + "/Users/xl/.codex/plugins/cache/openai-curated/example/hash1234567890/skills-with-a-very-long-shared-prefix", + ) + .abs(); + let alpha = skill_with_path("alpha-skill", &root.join("alpha/SKILL.md")); + let beta = skill_with_path("beta-skill", &root.join("beta/SKILL.md")); + let outcome = outcome_with_roots(vec![alpha.clone(), beta.clone()], vec![root.clone()]); + let plan = build_alias_plan( + &outcome, + &[alpha.clone(), beta.clone()], + SkillMetadataBudget::Characters(usize::MAX), + ) + .expect("alias plan should build"); + let alpha_cost = SkillMetadataBudget::Characters(usize::MAX).cost(&format!( + "{}\n", + SkillLine::with_path(&alpha, render_skill_path_with_aliases(&alpha, &plan)) + .render_minimum() + )); + let rendered = build_aliased_available_skills( + &outcome, + &[alpha, beta], + SkillMetadataBudget::Characters(plan.table_cost + alpha_cost), + ) + .expect("skills should render"); + + assert_eq!(rendered.report.included_count, 1); + assert_eq!( + rendered.skill_root_lines, + vec![format!("- `r0` = `{}`", normalized_path(&root))] + ); + assert_eq!( + rendered.skill_lines, + vec!["- alpha-skill: (file: r0/alpha/SKILL.md)"] + ); + } + + #[test] + fn outcome_rendering_uses_each_skill_root_for_multiple_roots_in_one_plugin_version() { + let skills_root = + test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated/github/hash123/skills") + .abs(); + let extra_root = test_path_buf( + "/Users/xl/.codex/plugins/cache/openai-curated/github/hash123/extra-skills", + ) + .abs(); + let fix_ci = skill_with_path("github:gh-fix-ci", &skills_root.join("gh-fix-ci/SKILL.md")); + let yeet = skill_with_path("github:yeet", &extra_root.join("yeet/SKILL.md")); + let outcome = outcome_with_roots( + vec![fix_ci.clone(), yeet.clone()], + vec![skills_root.clone(), extra_root.clone()], + ); + let plan = build_alias_plan( + &outcome, + &[fix_ci, yeet], + SkillMetadataBudget::Characters(usize::MAX), + ) + .expect("alias plan should build"); + + assert_eq!( + plan.aliases.skill_root_lines, + vec![ + format!("- `r0` = `{}`", normalized_path(&skills_root)), + format!("- `r1` = `{}`", normalized_path(&extra_root)), + ] + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path("github:gh-fix-ci", &skills_root.join("gh-fix-ci/SKILL.md")), + &plan + ), + "r0/gh-fix-ci/SKILL.md" + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path("github:yeet", &extra_root.join("yeet/SKILL.md")), + &plan + ), + "r1/yeet/SKILL.md" + ); + } + + #[test] + fn outcome_rendering_extracts_plugin_marketplace_root_for_multiple_plugins() { + let github_root = + test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated/github/hash123/skills") + .abs(); + let slack_root = + test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated/slack/hash456/skills") + .abs(); + let marketplace_root = test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated").abs(); + let github = skill_with_path("github:gh-fix-ci", &github_root.join("gh-fix-ci/SKILL.md")); + let slack = skill_with_path( + "slack:daily-digest", + &slack_root.join("daily-digest/SKILL.md"), + ); + let outcome = outcome_with_roots( + vec![github.clone(), slack.clone()], + vec![github_root.clone(), slack_root.clone()], + ); + let plan = build_alias_plan( + &outcome, + &[github, slack], + SkillMetadataBudget::Characters(usize::MAX), + ) + .expect("alias plan should build"); + + assert_eq!( + plan.aliases.skill_root_lines, + vec![format!("- `r0` = `{}`", normalized_path(&marketplace_root))] + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path("github:gh-fix-ci", &github_root.join("gh-fix-ci/SKILL.md")), + &plan + ), + "r0/github/hash123/skills/gh-fix-ci/SKILL.md" + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path( + "slack:daily-digest", + &slack_root.join("daily-digest/SKILL.md") + ), + &plan + ), + "r0/slack/hash456/skills/daily-digest/SKILL.md" + ); + } + + #[test] + fn outcome_rendering_uses_one_marketplace_root_for_multiple_plugin_versions() { + let skills_root = + test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated/github/hash123/skills") + .abs(); + let extra_root = test_path_buf( + "/Users/xl/.codex/plugins/cache/openai-curated/github/hash456/extra-skills", + ) + .abs(); + let marketplace_root = test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated").abs(); + let fix_ci = skill_with_path("github:gh-fix-ci", &skills_root.join("gh-fix-ci/SKILL.md")); + let yeet = skill_with_path("github:yeet", &extra_root.join("yeet/SKILL.md")); + let outcome = outcome_with_roots( + vec![fix_ci.clone(), yeet.clone()], + vec![skills_root.clone(), extra_root.clone()], + ); + let plan = build_alias_plan( + &outcome, + &[fix_ci, yeet], + SkillMetadataBudget::Characters(usize::MAX), + ) + .expect("alias plan should build"); + + assert_eq!( + plan.aliases.skill_root_lines, + vec![format!("- `r0` = `{}`", normalized_path(&marketplace_root))] + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path("github:gh-fix-ci", &skills_root.join("gh-fix-ci/SKILL.md")), + &plan + ), + "r0/github/hash123/skills/gh-fix-ci/SKILL.md" + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path("github:yeet", &extra_root.join("yeet/SKILL.md")), + &plan + ), + "r0/github/hash456/extra-skills/yeet/SKILL.md" + ); + } + + fn skill_with_path(name: &str, path: &AbsolutePathBuf) -> SkillMetadata { + let mut skill = make_skill(name, SkillScope::User); + skill.path_to_skills_md = path.clone(); + skill + } } diff --git a/codex-rs/core/BUILD.bazel b/codex-rs/core/BUILD.bazel index df5f4da1fa..dbca9ab63a 100644 --- a/codex-rs/core/BUILD.bazel +++ b/codex-rs/core/BUILD.bazel @@ -19,9 +19,7 @@ codex_rust_crate( "Cargo.toml", ], allow_empty = True, - ) + [ - "//codex-rs:node-version.txt", - ], + ), rustc_env = { # Keep manifest-root path lookups inside the Bazel execroot for code # that relies on env!("CARGO_MANIFEST_DIR"). @@ -48,7 +46,7 @@ codex_rust_crate( "//:AGENTS.md", ], test_shard_counts = { - "core-all-test": 8, + "core-all-test": 16, "core-unit-tests": 8, }, test_tags = ["no-sandbox"], diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 86d6f8b0f3..c95b57b718 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -39,6 +39,8 @@ codex-exec-server = { workspace = true } codex-features = { workspace = true } codex-feedback = { workspace = true } codex-login = { workspace = true } +codex-memories-read = { workspace = true } +codex-memories-write = { workspace = true } codex-mcp = { workspace = true } codex-model-provider-info = { workspace = true } codex-models-manager = { workspace = true } @@ -53,6 +55,7 @@ codex-model-provider = { workspace = true } codex-protocol = { workspace = true } codex-response-debug-context = { workspace = true } codex-rollout = { workspace = true } +codex-rollout-trace = { workspace = true } codex-rmcp-client = { workspace = true } codex-sandboxing = { workspace = true } codex-state = { workspace = true } @@ -118,10 +121,6 @@ url = { workspace = true } uuid = { workspace = true, features = ["serde", "v4", "v5"] } which = { workspace = true } whoami = { workspace = true } -zip = { workspace = true } - -[target.'cfg(target_os = "macos")'.dependencies] -core-foundation = "0.9" # Build OpenSSL from source for musl builds. [target.x86_64-unknown-linux-musl.dependencies] @@ -131,13 +130,6 @@ openssl-sys = { workspace = true, features = ["vendored"] } [target.aarch64-unknown-linux-musl.dependencies] openssl-sys = { workspace = true, features = ["vendored"] } -[target.'cfg(target_os = "windows")'.dependencies] -windows-sys = { version = "0.52", features = [ - "Win32_Foundation", - "Win32_System_Com", - "Win32_UI_Shell", -] } - [target.'cfg(unix)'.dependencies] codex-shell-escalation = { workspace = true } diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index 2e311790d9..be222a1673 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -51,23 +51,18 @@ sandboxed shell commands that would enter the bubblewrap path before invoking ### Windows Legacy `SandboxPolicy` / `sandbox_mode` configs are still supported on -Windows. - -The elevated setup/runner backend supports legacy `ReadOnlyAccess::Restricted` -for `read-only` and `workspace-write` policies. Restricted read access honors -explicit readable roots plus the command `cwd`, and keeps writable roots -readable when `workspace-write` is used. - -When `include_platform_defaults = true`, the elevated Windows backend adds -backend-managed system read roots required for basic execution, such as -`C:\Windows`, `C:\Program Files`, `C:\Program Files (x86)`, and -`C:\ProgramData`. When it is `false`, those extra system roots are omitted. +Windows. Legacy `read-only` and `workspace-write` policies imply full +filesystem read access; exact readable roots are represented by split +filesystem policies instead. The elevated Windows sandbox also supports: - legacy `ReadOnly` and `WorkspaceWrite` behavior - split filesystem policies that need exact readable roots, exact writable roots, or extra read-only carveouts under writable roots +- backend-managed system read roots required for basic execution, such as + `C:\Windows`, `C:\Program Files`, `C:\Program Files (x86)`, and + `C:\ProgramData`, when a split filesystem policy requests platform defaults The unelevated restricted-token backend still supports the legacy full-read Windows model for legacy `ReadOnly` and `WorkspaceWrite` behavior. It also diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 3f97359917..bdccafcb53 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -36,6 +36,10 @@ "$ref": "#/definitions/AgentRoleToml" }, "properties": { + "interrupt_message": { + "description": "Whether to record a model-visible message when an agent turn is interrupted. Defaults to true.", + "type": "boolean" + }, "job_max_runtime_seconds": { "description": "Default maximum runtime in seconds for agent job workers.", "format": "uint64", @@ -169,9 +173,10 @@ "type": "object" }, "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "type": "string" @@ -292,6 +297,15 @@ } ] }, + "AutoReviewToml": { + "properties": { + "policy": { + "description": "Additional policy instructions inserted into the guardian prompt.", + "type": "string" + } + }, + "type": "object" + }, "BundledSkillsConfig": { "additionalProperties": false, "properties": { @@ -341,6 +355,9 @@ "apps": { "type": "boolean" }, + "browser_use": { + "type": "boolean" + }, "child_agents_md": { "type": "boolean" }, @@ -365,6 +382,9 @@ "collaboration_modes": { "type": "boolean" }, + "computer_use": { + "type": "boolean" + }, "connectors": { "type": "boolean" }, @@ -380,6 +400,9 @@ "enable_fanout": { "type": "boolean" }, + "enable_mcp_apps": { + "type": "boolean" + }, "enable_request_compression": { "type": "boolean" }, @@ -401,7 +424,7 @@ "fast_mode": { "type": "boolean" }, - "general_analytics": { + "goals": { "type": "boolean" }, "guardian_approval": { @@ -413,6 +436,9 @@ "image_generation": { "type": "boolean" }, + "in_app_browser": { + "type": "boolean" + }, "include_apply_patch_tool": { "type": "boolean" }, @@ -452,6 +478,9 @@ "remote_models": { "type": "boolean" }, + "remote_plugin": { + "type": "boolean" + }, "request_permissions": { "type": "boolean" }, @@ -497,6 +526,9 @@ "telepathy": { "type": "boolean" }, + "terminal_resize_reflow": { + "type": "boolean" + }, "tool_call_mcp_elicitation": { "type": "boolean" }, @@ -557,16 +589,6 @@ "include_permissions_instructions": { "type": "boolean" }, - "js_repl_node_module_dirs": { - "description": "Ordered list of directories to search for Node modules in `js_repl`.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - }, - "js_repl_node_path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, "model": { "type": "string" }, @@ -612,7 +634,12 @@ "$ref": "#/definitions/SandboxMode" }, "service_tier": { - "$ref": "#/definitions/ServiceTier" + "allOf": [ + { + "$ref": "#/definitions/ServiceTier" + } + ], + "description": "Optional explicit service tier preference for new turns (`fast` or `flex`)." }, "tools": { "$ref": "#/definitions/ToolsToml" @@ -739,16 +766,16 @@ "additionalProperties": false, "properties": { "disable_warnings": { - "description": "Disable all ghost snapshot warning events.", + "description": "Legacy no-op setting retained for compatibility.", "type": "boolean" }, "ignore_large_untracked_dirs": { - "description": "Ignore untracked directories that contain this many files or more. (Still emits a warning unless warnings are disabled.)", + "description": "Legacy no-op setting retained for compatibility.", "format": "int64", "type": "integer" }, "ignore_large_untracked_files": { - "description": "Exclude untracked files larger than this many bytes from ghost snapshots.", + "description": "Legacy no-op setting retained for compatibility.", "format": "int64", "type": "integer" } @@ -829,6 +856,117 @@ } ] }, + "HookEventsToml": { + "properties": { + "PermissionRequest": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, + "PostToolUse": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, + "PreToolUse": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, + "SessionStart": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, + "Stop": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, + "UserPromptSubmit": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + } + }, + "type": "object" + }, + "HookHandlerConfig": { + "oneOf": [ + { + "properties": { + "async": { + "default": false, + "type": "boolean" + }, + "command": { + "type": "string" + }, + "statusMessage": { + "default": null, + "type": "string" + }, + "timeout": { + "default": null, + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "type": { + "enum": [ + "command" + ], + "type": "string" + } + }, + "required": [ + "command", + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "prompt" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "agent" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + }, "MarketplaceConfig": { "additionalProperties": false, "properties": { @@ -879,6 +1017,22 @@ ], "type": "string" }, + "MatcherGroup": { + "properties": { + "hooks": { + "default": [], + "items": { + "$ref": "#/definitions/HookHandlerConfig" + }, + "type": "array" + }, + "matcher": { + "default": null, + "type": "string" + } + }, + "type": "object" + }, "McpServerEnvVar": { "anyOf": [ { @@ -1031,6 +1185,10 @@ "profile": { "description": "AWS profile name to use. When unset, the AWS SDK default chain decides.", "type": "string" + }, + "region": { + "description": "AWS region to use for provider-specific endpoints.", + "type": "string" } }, "type": "object" @@ -1152,6 +1310,11 @@ "hide_spawn_agent_metadata": { "type": "boolean" }, + "max_concurrent_threads_per_session": { + "format": "uint", + "minimum": 1.0, + "type": "integer" + }, "usage_hint_enabled": { "type": "boolean" }, @@ -1247,6 +1410,10 @@ }, "description": "Tracks scopes where external config migration prompts should be suppressed." }, + "fast_default_opt_out": { + "description": "Tracks whether the user opted out of Codex-managed fast defaults.", + "type": "boolean" + }, "hide_full_access_warning": { "description": "Tracks whether the user has acknowledged the full access warning prompt.", "type": "boolean" @@ -1547,7 +1714,7 @@ }, "RawMcpServerConfig": { "additionalProperties": false, - "description": "Raw MCP config shape used for deserialization and JSON Schema generation.\n\nKeep `TryFrom for McpServerConfig` exhaustively destructuring this struct so new TOML fields cannot be added here without updating the validation/mapping logic that produces [`McpServerConfig`].", + "description": "Raw MCP config shape used for deserialization and supported-field JSON Schema generation.\n\nFields that are accepted only to produce targeted validation errors should be skipped in the generated schema.\n\nKeep `TryFrom for McpServerConfig` exhaustively destructuring this struct so new TOML fields cannot be added here without updating the validation/mapping logic that produces [`McpServerConfig`].", "properties": { "args": { "default": null, @@ -1556,9 +1723,6 @@ }, "type": "array" }, - "bearer_token": { - "type": "string" - }, "bearer_token_env_var": { "type": "string" }, @@ -1930,6 +2094,42 @@ }, "type": "object" }, + "ThreadStoreToml": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "local" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "endpoint": { + "type": "string" + }, + "type": { + "enum": [ + "remote" + ], + "type": "string" + } + }, + "required": [ + "endpoint", + "type" + ], + "type": "object" + } + ] + }, "ToolSuggestConfig": { "additionalProperties": false, "properties": { @@ -2060,9 +2260,16 @@ }, "type": "array" }, + "terminal_resize_reflow_max_rows": { + "default": null, + "description": "Trim terminal resize-reflow replay to the most recent rendered terminal rows when the transcript exceeds this cap. Omit to use Codex's terminal-specific default. Set to `0` to keep all rendered rows.", + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, "terminal_title": { "default": null, - "description": "Ordered list of terminal title item identifiers.\n\nWhen set, the TUI renders the selected items into the terminal window/tab title. When unset, the TUI defaults to: `spinner` and `project`.", + "description": "Ordered list of terminal title item identifiers.\n\nWhen set, the TUI renders the selected items into the terminal window/tab title. When unset, the TUI defaults to: `activity` and `project`. The `activity` item spins while working and shows an action-required message when blocked on the user.", "items": { "type": "string" }, @@ -2246,6 +2453,15 @@ "default": null, "description": "Machine-local realtime audio device preferences used by realtime voice." }, + "auto_review": { + "allOf": [ + { + "$ref": "#/definitions/AutoReviewToml" + } + ], + "default": null, + "description": "Optional policy instructions for the guardian auto-reviewer." + }, "background_terminal_max_timeout": { "description": "Maximum poll window for background terminal output (`write_stdin`), in milliseconds. Default: `300000` (5 minutes).", "format": "uint64", @@ -2313,6 +2529,18 @@ "description": "Experimental / do not use. Replaces the synthesized realtime startup context appended to websocket session instructions. An empty string disables startup context injection entirely.", "type": "string" }, + "experimental_thread_config_endpoint": { + "description": "Experimental / do not use. When set, app-server fetches thread-scoped config from a remote service at this endpoint.", + "type": "string" + }, + "experimental_thread_store": { + "allOf": [ + { + "$ref": "#/definitions/ThreadStoreToml" + } + ], + "description": "Experimental / do not use. Selects the thread store implementation." + }, "experimental_thread_store_endpoint": { "description": "Experimental / do not use. When set, app-server uses a remote thread store at this endpoint instead of the local filesystem/SQLite store.", "type": "string" @@ -2337,6 +2565,9 @@ "apps": { "type": "boolean" }, + "browser_use": { + "type": "boolean" + }, "child_agents_md": { "type": "boolean" }, @@ -2361,6 +2592,9 @@ "collaboration_modes": { "type": "boolean" }, + "computer_use": { + "type": "boolean" + }, "connectors": { "type": "boolean" }, @@ -2376,6 +2610,9 @@ "enable_fanout": { "type": "boolean" }, + "enable_mcp_apps": { + "type": "boolean" + }, "enable_request_compression": { "type": "boolean" }, @@ -2397,7 +2634,7 @@ "fast_mode": { "type": "boolean" }, - "general_analytics": { + "goals": { "type": "boolean" }, "guardian_approval": { @@ -2409,6 +2646,9 @@ "image_generation": { "type": "boolean" }, + "in_app_browser": { + "type": "boolean" + }, "include_apply_patch_tool": { "type": "boolean" }, @@ -2448,6 +2688,9 @@ "remote_models": { "type": "boolean" }, + "remote_plugin": { + "type": "boolean" + }, "request_permissions": { "type": "boolean" }, @@ -2493,6 +2736,9 @@ "telepathy": { "type": "boolean" }, + "terminal_resize_reflow": { + "type": "boolean" + }, "tool_call_mcp_elicitation": { "type": "boolean" }, @@ -2578,7 +2824,7 @@ } ], "default": null, - "description": "Settings for ghost snapshots (used for undo)." + "description": "Compatibility-only settings retained so legacy `ghost_snapshot` config still loads." }, "hide_agent_reasoning": { "description": "When set to `true`, `AgentReasoning` events will be hidden from the UI/output. Defaults to `false`.", @@ -2593,6 +2839,14 @@ "default": null, "description": "Settings that govern if and what will be written to `~/.codex/history.jsonl`." }, + "hooks": { + "allOf": [ + { + "$ref": "#/definitions/HookEventsToml" + } + ], + "description": "Lifecycle hooks configured inline in TOML." + }, "include_apps_instructions": { "description": "Whether to inject the `` developer block.", "type": "boolean" @@ -2609,21 +2863,6 @@ "description": "System instructions.", "type": "string" }, - "js_repl_node_module_dirs": { - "description": "Ordered list of directories to search for Node modules in `js_repl`.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - }, - "js_repl_node_path": { - "allOf": [ - { - "$ref": "#/definitions/AbsolutePathBuf" - } - ], - "description": "Optional absolute path to the Node runtime used by `js_repl`." - }, "log_dir": { "allOf": [ { diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 1c30d613e0..433e3b8c91 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -28,6 +28,7 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::user_input::UserInput; use codex_rollout::state_db; use codex_state::DirectionalThreadSpawnEdgeStatus; @@ -52,6 +53,7 @@ pub(crate) enum SpawnAgentForkMode { pub(crate) struct SpawnAgentOptions { pub(crate) fork_parent_spawn_call_id: Option, pub(crate) fork_mode: Option, + pub(crate) environments: Option>, } #[derive(Clone, Debug)] @@ -112,7 +114,6 @@ fn keep_forked_rollout_item(item: &RolloutItem) -> bool { | ResponseItem::ToolSearchOutput { .. } | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } - | ResponseItem::GhostSnapshot { .. } | ResponseItem::Compaction { .. } | ResponseItem::Other, ) => false, @@ -163,15 +164,14 @@ impl AgentControl { initial_operation: Op, session_source: Option, ) -> CodexResult { - Ok(self - .spawn_agent_internal( - config, - initial_operation, - session_source, - SpawnAgentOptions::default(), - ) - .await? - .thread_id) + let spawned_agent = Box::pin(self.spawn_agent_internal( + config, + initial_operation, + session_source, + SpawnAgentOptions::default(), + )) + .await?; + Ok(spawned_agent.thread_id) } /// Spawn an agent thread with some metadata. @@ -182,7 +182,7 @@ impl AgentControl { session_source: Option, options: SpawnAgentOptions, // TODO(jif) drop with new fork. ) -> CodexResult { - self.spawn_agent_internal(config, initial_operation, session_source, options) + Box::pin(self.spawn_agent_internal(config, initial_operation, session_source, options)) .await } @@ -247,6 +247,7 @@ impl AgentControl { /*metrics_service_name*/ None, inherited_shell_snapshot, inherited_exec_policy, + options.environments.clone(), ) .await? } @@ -260,7 +261,6 @@ impl AgentControl { parent_thread_id, .. }, )) = notification_source.as_ref() - && new_thread.thread.enabled(Feature::GeneralAnalytics) { let client_metadata = match state.get_thread(*parent_thread_id).await { Ok(parent_thread) => { @@ -406,6 +406,7 @@ impl AgentControl { /*persist_extended_history*/ false, inherited_shell_snapshot, inherited_exec_policy, + options.environments.clone(), ) .await } @@ -418,9 +419,12 @@ impl AgentControl { session_source: SessionSource, ) -> CodexResult { let root_depth = thread_spawn_depth(&session_source).unwrap_or(0); - let resumed_thread_id = self - .resume_single_agent_from_rollout(config.clone(), thread_id, session_source) - .await?; + let resumed_thread_id = Box::pin(self.resume_single_agent_from_rollout( + config.clone(), + thread_id, + session_source, + )) + .await?; let state = self.upgrade()?; let Ok(resumed_thread) = state.get_thread(resumed_thread_id).await else { return Ok(resumed_thread_id); @@ -698,7 +702,7 @@ impl AgentControl { { warn!("failed to persist thread-spawn edge status for {agent_id}: {err}"); } - self.shutdown_agent_tree(agent_id).await + Box::pin(self.shutdown_agent_tree(agent_id)).await } /// Shut down `agent_id` and any live descendants reachable from the in-memory spawn tree. diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 1a5c38723f..daa86718fa 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -66,7 +66,6 @@ fn assistant_message(text: &str, phase: Option) -> ResponseItem { content: vec![ContentItem::OutputText { text: text.to_string(), }], - end_turn: None, phase, } } @@ -278,6 +277,7 @@ async fn on_event_updates_status_from_task_complete() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -428,6 +428,7 @@ async fn send_input_submits_user_message() { let expected = ( thread_id, Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "hello from tests".to_string(), text_elements: Vec::new(), @@ -517,7 +518,6 @@ async fn append_message_records_assistant_message() { content: vec![ContentItem::InputText { text: message.to_string(), }], - end_turn: None, phase: None, }, ) @@ -575,6 +575,7 @@ async fn spawn_agent_creates_thread_and_sends_prompt() { let expected = ( thread_id, Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "spawned".to_string(), text_elements: Vec::new(), @@ -654,6 +655,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { SpawnAgentOptions { fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()), fork_mode: Some(SpawnAgentForkMode::FullHistory), + ..Default::default() }, ) .await @@ -674,7 +676,6 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { content: vec![ContentItem::InputText { text: "parent seed context".to_string(), }], - end_turn: None, phase: None, }, assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)), @@ -688,6 +689,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { let expected = ( child_thread_id, Op::UserInput { + environments: None, items: vec![UserInput::Text { text: "child task".to_string(), text_elements: Vec::new(), @@ -747,6 +749,7 @@ async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() { SpawnAgentOptions { fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()), fork_mode: Some(SpawnAgentForkMode::FullHistory), + ..Default::default() }, ) .await @@ -856,6 +859,7 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() { SpawnAgentOptions { fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()), fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)), + ..Default::default() }, ) .await @@ -1218,6 +1222,7 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1304,6 +1309,7 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 9569c02d71..2ab16cd22a 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -11,13 +11,13 @@ use crate::config::Config; use crate::config::ConfigOverrides; use crate::config::agent_roles::parse_agent_role_file_contents; use crate::config::deserialize_config_toml_with_base; -use crate::config_loader::ConfigLayerEntry; -use crate::config_loader::ConfigLayerStack; -use crate::config_loader::ConfigLayerStackOrdering; -use crate::config_loader::resolve_relative_paths_in_config_toml; use anyhow::anyhow; use codex_app_server_protocol::ConfigLayerSource; +use codex_config::ConfigLayerEntry; +use codex_config::ConfigLayerStack; +use codex_config::ConfigLayerStackOrdering; use codex_config::config_toml::ConfigToml; +use codex_config::loader::resolve_relative_paths_in_config_toml; use codex_exec_server::LOCAL_FS; use std::collections::BTreeMap; use std::collections::BTreeSet; @@ -267,7 +267,6 @@ mod reload { model_provider: preserve_current_provider.then(|| config.model_provider_id.clone()), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - js_repl_node_path: config.js_repl_node_path.clone(), ..Default::default() } } diff --git a/codex-rs/core/src/agent/role_tests.rs b/codex-rs/core/src/agent/role_tests.rs index f379fbef16..eceaaa9200 100644 --- a/codex-rs/core/src/agent/role_tests.rs +++ b/codex-rs/core/src/agent/role_tests.rs @@ -2,9 +2,9 @@ use super::*; use crate::SkillsManager; use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; -use crate::config_loader::ConfigLayerStackOrdering; use crate::plugins::PluginsManager; use crate::skills_load_input_from_config; +use codex_config::ConfigLayerStackOrdering; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Verbosity; use codex_protocol::openai_models::ReasoningEffort; @@ -574,7 +574,7 @@ writable_roots = ["./sandbox-root"] false ); - match &*config.permissions.sandbox_policy { + match &config.legacy_sandbox_policy() { SandboxPolicy::WorkspaceWrite { network_access, .. } => { assert_eq!(*network_access, true); } diff --git a/codex-rs/core/src/agent/status.rs b/codex-rs/core/src/agent/status.rs index c343e19503..43be718865 100644 --- a/codex-rs/core/src/agent/status.rs +++ b/codex-rs/core/src/agent/status.rs @@ -8,7 +8,8 @@ pub(crate) fn agent_status_from_event(msg: &EventMsg) -> Option { EventMsg::TurnStarted(_) => Some(AgentStatus::Running), EventMsg::TurnComplete(ev) => Some(AgentStatus::Completed(ev.last_agent_message.clone())), EventMsg::TurnAborted(ev) => match ev.reason { - codex_protocol::protocol::TurnAbortReason::Interrupted => { + codex_protocol::protocol::TurnAbortReason::Interrupted + | codex_protocol::protocol::TurnAbortReason::BudgetLimited => { Some(AgentStatus::Interrupted) } _ => Some(AgentStatus::Errored(format!("{:?}", ev.reason))), diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index a1a883e839..7a9fd74932 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -16,11 +16,11 @@ //! 3. We do **not** walk past the project root. use crate::config::Config; -use crate::config_loader::ConfigLayerStackOrdering; -use crate::config_loader::default_project_root_markers; -use crate::config_loader::merge_toml_values; -use crate::config_loader::project_root_markers_from_config; use codex_app_server_protocol::ConfigLayerSource; +use codex_config::ConfigLayerStackOrdering; +use codex_config::default_project_root_markers; +use codex_config::merge_toml_values; +use codex_config::project_root_markers_from_config; use codex_exec_server::Environment; use codex_exec_server::ExecutorFileSystem; use codex_features::Feature; @@ -42,41 +42,6 @@ pub const LOCAL_AGENTS_MD_FILENAME: &str = "AGENTS.override.md"; /// be concatenated with the following separator. const AGENTS_MD_SEPARATOR: &str = "\n\n--- project-doc ---\n\n"; -fn render_js_repl_instructions(config: &Config) -> Option { - if !config.features.enabled(Feature::JsRepl) { - return None; - } - - let mut section = String::from("## JavaScript REPL (Node)\n"); - section.push_str( - "- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n", - ); - section.push_str("- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n"); - section.push_str( - "- Helpers: `codex.cwd`, `codex.homeDir`, `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n", - ); - section.push_str("- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n"); - section.push_str("- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n"); - section.push_str("- `codex.tool(...)` and `codex.emitImage(...)` keep stable helper identities across cells. Saved references and persisted objects can reuse them in later cells, but async callbacks that fire after a cell finishes still fail because no exec is active.\n"); - section.push_str("- Request full-resolution image processing with `detail: \"original\"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: \"original\"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents.\n"); - section.push_str("- Raw MCP image blocks can request the same behavior by returning `_meta: { \"codex/imageDetail\": \"original\" }` on the image content item.\n"); - section.push_str("- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\", detail: \"original\" })`.\n"); - section.push_str("- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\", detail: \"original\" }))`.\n"); - section.push_str("- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n"); - section.push_str("- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n"); - section.push_str("- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n"); - - if config.features.enabled(Feature::JsReplToolsOnly) { - section.push_str("- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n"); - section - .push_str("- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n"); - } - - section.push_str("- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`."); - - Some(section) -} - /// Resolves AGENTS.md files into model-visible user instructions and source /// paths. pub struct AgentsMdManager<'a> { @@ -147,13 +112,6 @@ impl<'a> AgentsMdManager<'a> { } }; - if let Some(js_repl_section) = render_js_repl_instructions(self.config) { - if !output.is_empty() { - output.push_str("\n\n"); - } - output.push_str(&js_repl_section); - } - if self.config.features.enabled(Feature::ChildAgentsMd) { if !output.is_empty() { output.push_str("\n\n"); diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index 012724b43e..a3a7544823 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -199,40 +199,6 @@ async fn zero_byte_limit_disables_discovery() { assert_eq!(discovery, Vec::::new()); } -#[tokio::test] -async fn js_repl_instructions_are_appended_when_enabled() { - let tmp = tempfile::tempdir().expect("tempdir"); - let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; - cfg.features - .enable(Feature::JsRepl) - .expect("test config should allow js_repl"); - - let res = get_user_instructions(&cfg) - .await - .expect("js_repl instructions expected"); - let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.cwd`, `codex.homeDir`, `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- `codex.tool(...)` and `codex.emitImage(...)` keep stable helper identities across cells. Saved references and persisted objects can reuse them in later cells, but async callbacks that fire after a cell finishes still fail because no exec is active.\n- Request full-resolution image processing with `detail: \"original\"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: \"original\"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents.\n- Raw MCP image blocks can request the same behavior by returning `_meta: { \"codex/imageDetail\": \"original\" }` on the image content item.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\", detail: \"original\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\", detail: \"original\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`."; - assert_eq!(res, expected); -} - -#[tokio::test] -async fn js_repl_tools_only_instructions_are_feature_gated() { - let tmp = tempfile::tempdir().expect("tempdir"); - let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; - let mut features = cfg.features.get().clone(); - features - .enable(Feature::JsRepl) - .enable(Feature::JsReplToolsOnly); - cfg.features - .set(features) - .expect("test config should allow js_repl tool restrictions"); - - let res = get_user_instructions(&cfg) - .await - .expect("js_repl instructions expected"); - let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.cwd`, `codex.homeDir`, `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- `codex.tool(...)` and `codex.emitImage(...)` keep stable helper identities across cells. Saved references and persisted objects can reuse them in later cells, but async callbacks that fire after a cell finishes still fail because no exec is active.\n- Request full-resolution image processing with `detail: \"original\"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: \"original\"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents.\n- Raw MCP image blocks can request the same behavior by returning `_meta: { \"codex/imageDetail\": \"original\" }` on the image content item.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\", detail: \"original\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\", detail: \"original\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`."; - assert_eq!(res, expected); -} - /// When both system instructions and AGENTS.md docs are present the two /// should be concatenated with the separator. #[tokio::test] diff --git a/codex-rs/core/src/apply_patch.rs b/codex-rs/core/src/apply_patch.rs index c05a459049..d5ebe4fe1f 100644 --- a/codex-rs/core/src/apply_patch.rs +++ b/codex-rs/core/src/apply_patch.rs @@ -38,7 +38,7 @@ pub(crate) async fn apply_patch( match assess_patch_safety( &action, turn_context.approval_policy.value(), - turn_context.sandbox_policy.get(), + &turn_context.permission_profile(), file_system_sandbox_policy, &turn_context.cwd, turn_context.windows_sandbox_level, diff --git a/codex-rs/core/src/arc_monitor.rs b/codex-rs/core/src/arc_monitor.rs index ecd7f39666..c7f12e1024 100644 --- a/codex-rs/core/src/arc_monitor.rs +++ b/codex-rs/core/src/arc_monitor.rs @@ -9,7 +9,6 @@ use crate::compact::content_items_to_text; use crate::event_mapping::is_contextual_user_message_content; use crate::session::session::Session; use crate::session::turn_context::TurnContext; -use codex_login::CodexAuth; use codex_login::default_client::build_reqwest_client; use codex_protocol::models::MessagePhase; use codex_protocol::models::ResponseItem; @@ -104,28 +103,15 @@ pub(crate) async fn monitor_action( ) -> ArcMonitorOutcome { let auth = match turn_context.auth_manager.as_ref() { Some(auth_manager) => match auth_manager.auth().await { - Some(auth) if auth.is_chatgpt_auth() => Some(auth), + Some(auth) if auth.uses_codex_backend() => Some(auth), _ => None, }, None => None, }; - let token = if let Some(token) = read_non_empty_env_var(CODEX_ARC_MONITOR_TOKEN) { - token - } else { - let Some(auth) = auth.as_ref() else { - return ArcMonitorOutcome::Ok; - }; - match auth.get_token() { - Ok(token) => token, - Err(err) => { - warn!( - error = %err, - "skipping safety monitor because auth token is unavailable" - ); - return ArcMonitorOutcome::Ok; - } - } - }; + let env_token = read_non_empty_env_var(CODEX_ARC_MONITOR_TOKEN); + if env_token.is_none() && auth.is_none() { + return ArcMonitorOutcome::Ok; + } let url = read_non_empty_env_var(CODEX_ARC_MONITOR_ENDPOINT_OVERRIDE).unwrap_or_else(|| { format!( @@ -143,13 +129,12 @@ pub(crate) async fn monitor_action( let body = build_arc_monitor_request(sess, turn_context, action, protection_client_callsite).await; let client = build_reqwest_client(); - let mut request = client - .post(&url) - .timeout(ARC_MONITOR_TIMEOUT) - .json(&body) - .bearer_auth(token); - if let Some(account_id) = auth.as_ref().and_then(CodexAuth::get_account_id) { - request = request.header("chatgpt-account-id", account_id); + let mut request = client.post(&url).timeout(ARC_MONITOR_TIMEOUT).json(&body); + if let Some(token) = env_token { + request = request.bearer_auth(token); + } else if let Some(auth) = auth.as_ref() { + request = + request.headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers()); } let response = match request.send().await { @@ -398,7 +383,6 @@ fn build_arc_monitor_message_item( | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } | ResponseItem::ImageGenerationCall { .. } - | ResponseItem::GhostSnapshot { .. } | ResponseItem::Compaction { .. } | ResponseItem::Other => None, } diff --git a/codex-rs/core/src/arc_monitor_tests.rs b/codex-rs/core/src/arc_monitor_tests.rs index 1cb29ce08c..4c2429cf5f 100644 --- a/codex-rs/core/src/arc_monitor_tests.rs +++ b/codex-rs/core/src/arc_monitor_tests.rs @@ -65,7 +65,6 @@ async fn build_arc_monitor_request_includes_relevant_history_and_null_policies() content: vec![ContentItem::InputText { text: "first request".to_string(), }], - end_turn: None, phase: None, }], &turn_context, @@ -94,7 +93,6 @@ async fn build_arc_monitor_request_includes_relevant_history_and_null_policies() content: vec![ContentItem::OutputText { text: "commentary".to_string(), }], - end_turn: None, phase: Some(MessagePhase::Commentary), }], &turn_context, @@ -108,7 +106,6 @@ async fn build_arc_monitor_request_includes_relevant_history_and_null_policies() content: vec![ContentItem::OutputText { text: "final response".to_string(), }], - end_turn: None, phase: Some(MessagePhase::FinalAnswer), }], &turn_context, @@ -122,7 +119,6 @@ async fn build_arc_monitor_request_includes_relevant_history_and_null_policies() content: vec![ContentItem::InputText { text: "latest request".to_string(), }], - end_turn: None, phase: None, }], &turn_context, @@ -277,7 +273,6 @@ async fn monitor_action_posts_expected_arc_request() { content: vec![ContentItem::InputText { text: "please run the tool".to_string(), }], - end_turn: None, phase: None, }], &turn_context, @@ -358,7 +353,6 @@ async fn monitor_action_uses_env_url_and_token_overrides() { content: vec![ContentItem::InputText { text: "please run the tool".to_string(), }], - end_turn: None, phase: None, }], &turn_context, @@ -428,7 +422,6 @@ async fn monitor_action_rejects_legacy_response_fields() { content: vec![ContentItem::InputText { text: "please run the tool".to_string(), }], - end_turn: None, phase: None, }], &turn_context, diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 601b8106df..3997018eaa 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -80,6 +80,9 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::W3cTraceContext; +use codex_rollout_trace::CompactionTraceContext; +use codex_rollout_trace::InferenceTraceAttempt; +use codex_rollout_trace::InferenceTraceContext; use codex_tools::create_tools_json_for_responses_api; use eventsource_stream::Event; use eventsource_stream::EventStreamError; @@ -95,6 +98,7 @@ use tokio::sync::oneshot; use tokio::sync::oneshot::error::TryRecvError; use tokio_tungstenite::tungstenite::Error; use tokio_tungstenite::tungstenite::Message; +use tokio_util::sync::CancellationToken; use tracing::instrument; use tracing::trace; use tracing::warn; @@ -411,6 +415,7 @@ impl ModelClient { effort: Option, summary: ReasoningSummaryConfig, session_telemetry: &SessionTelemetry, + compaction_trace: &CompactionTraceContext, ) -> Result> { if prompt.input.is_empty() { return Ok(Vec::new()); @@ -446,7 +451,11 @@ impl ModelClient { } None }; - let text = create_text_param_for_request(verbosity, &prompt.output_schema); + let text = create_text_param_for_request( + verbosity, + &prompt.output_schema, + prompt.output_schema_strict, + ); let payload = ApiCompactionInput { model: &model_info.slug, input: &input, @@ -465,10 +474,13 @@ impl ModelClient { extra_headers.extend(build_conversation_headers(Some( self.state.conversation_id.to_string(), ))); - client + let trace_attempt = compaction_trace.start_attempt(&payload); + let result = client .compact_input(&payload, extra_headers) .await - .map_err(map_api_error) + .map_err(map_api_error); + trace_attempt.record_result(result.as_deref()); + result } pub(crate) async fn create_realtime_call_with_headers( @@ -859,7 +871,11 @@ impl ModelClientSession { } None }; - let text = create_text_param_for_request(verbosity, &prompt.output_schema); + let text = create_text_param_for_request( + verbosity, + &prompt.output_schema, + prompt.output_schema_strict, + ); let prompt_cache_key = Some(self.client.state.conversation_id.to_string()); let request = ResponsesApiRequest { model: model_info.slug.clone(), @@ -1112,7 +1128,7 @@ impl ModelClientSession { fn responses_request_compression(&self, auth: Option<&CodexAuth>) -> Compression { if self.client.state.enable_request_compression - && auth.is_some_and(CodexAuth::is_chatgpt_auth) + && auth.is_some_and(CodexAuth::uses_codex_backend) && self.client.state.provider.info().is_openai() { Compression::Zstd @@ -1148,6 +1164,7 @@ impl ModelClientSession { summary: ReasoningSummaryConfig, service_tier: Option, turn_metadata_header: Option<&str>, + inference_trace: &InferenceTraceContext, ) -> Result { if let Some(path) = &*CODEX_RS_SSE_FIXTURE { warn!(path, "Streaming from fixture"); @@ -1156,7 +1173,11 @@ impl ModelClientSession { self.client.state.provider.info().stream_idle_timeout(), ) .map_err(map_api_error)?; - let (stream, _last_request_rx) = map_response_stream(stream, session_telemetry.clone()); + let (stream, _last_request_rx) = map_response_stream( + stream, + session_telemetry.clone(), + InferenceTraceAttempt::disabled(), + ); return Ok(stream); } @@ -1190,6 +1211,8 @@ impl ModelClientSession { summary, service_tier, )?; + let inference_trace_attempt = inference_trace.start_attempt(); + inference_trace_attempt.record_started(&request); let client = ApiResponsesClient::new( transport, client_setup.api_provider, @@ -1200,12 +1223,18 @@ impl ModelClientSession { match stream_result { Ok(stream) => { - let (stream, _) = map_response_stream(stream, session_telemetry.clone()); + let (stream, _) = map_response_stream( + stream, + session_telemetry.clone(), + inference_trace_attempt, + ); return Ok(stream); } Err(ApiError::Transport( unauthorized_transport @ TransportError::Http { status, .. }, )) if status == StatusCode::UNAUTHORIZED => { + inference_trace_attempt + .record_failed(&unauthorized_transport, /*output_items*/ &[]); pending_retry = PendingUnauthorizedRetry::from_recovery( handle_unauthorized( unauthorized_transport, @@ -1216,7 +1245,11 @@ impl ModelClientSession { ); continue; } - Err(err) => return Err(map_api_error(err)), + Err(err) => { + let err = map_api_error(err); + inference_trace_attempt.record_failed(&err, /*output_items*/ &[]); + return Err(err); + } } } } @@ -1247,6 +1280,7 @@ impl ModelClientSession { turn_metadata_header: Option<&str>, warmup: bool, request_trace: Option, + inference_trace: &InferenceTraceContext, ) -> Result { let auth_manager = self.client.state.provider.auth_manager(); @@ -1321,17 +1355,33 @@ impl ModelClientSession { let ws_request = self.prepare_websocket_request(ws_payload, &request); self.websocket_session.last_request = Some(request); - let stream_result = self.websocket_session.connection.as_ref().ok_or_else(|| { - map_api_error(ApiError::Stream( - "websocket connection is unavailable".to_string(), - )) - })?; - let stream_result = stream_result + let inference_trace_attempt = if warmup { + // Prewarm sends `generate=false`; it is connection setup, not a + // model inference attempt that should appear in rollout traces. + InferenceTraceAttempt::disabled() + } else { + inference_trace.start_attempt() + }; + inference_trace_attempt.record_started(&ws_request); + let websocket_connection = + self.websocket_session.connection.as_ref().ok_or_else(|| { + map_api_error(ApiError::Stream( + "websocket connection is unavailable".to_string(), + )) + })?; + let stream_result = websocket_connection .stream_request(ws_request, self.websocket_session.connection_reused()) .await - .map_err(map_api_error)?; - let (stream, last_request_rx) = - map_response_stream(stream_result, session_telemetry.clone()); + .map_err(|err| { + let err = map_api_error(err); + inference_trace_attempt.record_failed(&err, /*output_items*/ &[]); + err + })?; + let (stream, last_request_rx) = map_response_stream( + stream_result, + session_telemetry.clone(), + inference_trace_attempt, + ); self.websocket_session.last_response_rx = Some(last_request_rx); return Ok(WebsocketStreamOutcome::Stream(stream)); } @@ -1390,6 +1440,7 @@ impl ModelClientSession { return Ok(()); } + let disabled_trace = InferenceTraceContext::disabled(); match self .stream_responses_websocket( prompt, @@ -1401,6 +1452,7 @@ impl ModelClientSession { turn_metadata_header, /*warmup*/ true, current_span_w3c_trace_context(), + &disabled_trace, ) .await { @@ -1429,7 +1481,9 @@ impl ModelClientSession { /// The caller is responsible for passing per-turn settings explicitly (model selection, /// reasoning settings, telemetry context, and turn metadata). This method will prefer the /// Responses WebSocket transport when the provider supports it and it remains healthy, and will - /// fall back to the HTTP Responses API transport otherwise. + /// fall back to the HTTP Responses API transport otherwise. The trace context may be enabled or + /// disabled, but is always explicit so transport paths do not need separate trace/no-trace + /// branches. pub async fn stream( &mut self, prompt: &Prompt, @@ -1439,6 +1493,7 @@ impl ModelClientSession { summary: ReasoningSummaryConfig, service_tier: Option, turn_metadata_header: Option<&str>, + inference_trace: &InferenceTraceContext, ) -> Result { let wire_api = self.client.state.provider.info().wire_api; match wire_api { @@ -1456,6 +1511,7 @@ impl ModelClientSession { turn_metadata_header, /*warmup*/ false, request_trace, + inference_trace, ) .await? { @@ -1474,6 +1530,7 @@ impl ModelClientSession { summary, service_tier, turn_metadata_header, + inference_trace, ) .await } @@ -1566,9 +1623,13 @@ fn parent_thread_id_header_value(session_source: &SessionSource) -> Option( api_stream: S, session_telemetry: SessionTelemetry, + inference_trace_attempt: InferenceTraceAttempt, ) -> (ResponseStream, oneshot::Receiver) where S: futures::Stream> @@ -1576,15 +1637,28 @@ where + Send + 'static, { - let (tx_event, rx_event) = mpsc::channel::>(1600); + let (tx_event, rx_event) = + mpsc::channel::>(RESPONSE_STREAM_CHANNEL_CAPACITY); let (tx_last_response, rx_last_response) = oneshot::channel::(); + let consumer_dropped = CancellationToken::new(); + let consumer_dropped_for_stream = consumer_dropped.clone(); tokio::spawn(async move { let mut logged_error = false; let mut tx_last_response = Some(tx_last_response); let mut items_added: Vec = Vec::new(); let mut api_stream = api_stream; - while let Some(event) = api_stream.next().await { + loop { + let event = tokio::select! { + _ = consumer_dropped.cancelled() => { + inference_trace_attempt.record_cancelled(STREAM_DROPPED_REASON, &items_added); + return; + } + event = api_stream.next() => event, + }; + let Some(event) = event else { + break; + }; match event { Ok(ResponseEvent::OutputItemDone(item)) => { items_added.push(item.clone()); @@ -1593,12 +1667,15 @@ where .await .is_err() { + inference_trace_attempt + .record_cancelled(STREAM_DROPPED_REASON, &items_added); return; } } Ok(ResponseEvent::Completed { response_id, token_usage, + end_turn, }) => { if let Some(usage) = &token_usage { session_telemetry.sse_event_completed( @@ -1609,6 +1686,11 @@ where usage.total_tokens, ); } + inference_trace_attempt.record_completed( + &response_id, + &token_usage, + &items_added, + ); if let Some(sender) = tx_last_response.take() { let _ = sender.send(LastResponse { response_id: response_id.clone(), @@ -1619,6 +1701,7 @@ where .send(Ok(ResponseEvent::Completed { response_id, token_usage, + end_turn, })) .await .is_err() @@ -1628,11 +1711,14 @@ where } Ok(event) => { if tx_event.send(Ok(event)).await.is_err() { + inference_trace_attempt + .record_cancelled(STREAM_DROPPED_REASON, &items_added); return; } } Err(err) => { let mapped = map_api_error(err); + inference_trace_attempt.record_failed(&mapped, &items_added); if !logged_error { session_telemetry.see_event_completed_failed(&mapped); logged_error = true; @@ -1643,9 +1729,17 @@ where } } } + inference_trace_attempt + .record_failed("stream closed before response.completed", &items_added); }); - (ResponseStream { rx_event }, rx_last_response) + ( + ResponseStream { + rx_event, + consumer_dropped: consumer_dropped_for_stream, + }, + rx_last_response, + ) } /// Handles a 401 response by optionally refreshing ChatGPT tokens once. @@ -1695,7 +1789,9 @@ impl AuthRequestTelemetryContext { Self { auth_mode: auth_mode.map(|mode| match mode { AuthMode::ApiKey => "ApiKey", - AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens => "Chatgpt", + AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens | AuthMode::AgentIdentity => { + "Chatgpt" + } }), auth_header_attached: auth_telemetry.attached, auth_header_name: auth_telemetry.name, diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index c1dfa29edc..efe2670652 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -13,6 +13,7 @@ use std::pin::Pin; use std::task::Context; use std::task::Poll; use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; /// Review thread system prompt. Edit `core/src/review_prompt.md` to customize. pub const REVIEW_PROMPT: &str = include_str!("../review_prompt.md"); @@ -23,7 +24,7 @@ pub const REVIEW_EXIT_INTERRUPTED_TMPL: &str = include_str!("../templates/review/exit_interrupted.xml"); /// API request payload for a single model turn -#[derive(Default, Debug, Clone)] +#[derive(Debug, Clone)] pub struct Prompt { /// Conversation context input items. pub input: Vec, @@ -42,6 +43,23 @@ pub struct Prompt { /// Optional the output schema for the model's response. pub output_schema: Option, + + /// Whether the Responses API should strictly validate `output_schema`. + pub output_schema_strict: bool, +} + +impl Default for Prompt { + fn default() -> Self { + Self { + input: Vec::new(), + tools: Vec::new(), + parallel_tool_calls: false, + base_instructions: BaseInstructions::default(), + personality: None, + output_schema: None, + output_schema_strict: true, + } + } } impl Prompt { @@ -158,6 +176,9 @@ fn strip_total_output_header(output: &str) -> Option<(&str, u32)> { pub struct ResponseStream { pub(crate) rx_event: mpsc::Receiver>, + /// Signals the mapper task that the consumer stopped polling before the + /// provider stream reached its own terminal event. + pub(crate) consumer_dropped: CancellationToken, } impl Stream for ResponseStream { @@ -168,6 +189,12 @@ impl Stream for ResponseStream { } } +impl Drop for ResponseStream { + fn drop(&mut self) { + self.consumer_dropped.cancel(); + } +} + #[cfg(test)] #[path = "client_common_tests.rs"] mod tests; diff --git a/codex-rs/core/src/client_common_tests.rs b/codex-rs/core/src/client_common_tests.rs index 7d9b71f560..f67e4f1fd8 100644 --- a/codex-rs/core/src/client_common_tests.rs +++ b/codex-rs/core/src/client_common_tests.rs @@ -52,9 +52,12 @@ fn serializes_text_schema_with_strict_format() { }, "required": ["answer"], }); - let text_controls = - create_text_param_for_request(/*verbosity*/ None, &Some(schema.clone())) - .expect("text controls"); + let text_controls = create_text_param_for_request( + /*verbosity*/ None, + &Some(schema.clone()), + /*output_schema_strict*/ true, + ) + .expect("text controls"); let req = ResponsesApiRequest { model: "gpt-5.4".to_string(), @@ -90,6 +93,29 @@ fn serializes_text_schema_with_strict_format() { assert_eq!(format.get("schema"), Some(&schema)); } +#[test] +fn serializes_text_schema_with_non_strict_format() { + let schema = serde_json::json!({ + "type": "object", + "properties": { + "answer": {"type": "string"}, + "rationale": {"type": "string"} + }, + "required": ["answer"], + "additionalProperties": false + }); + let text_controls = create_text_param_for_request( + /*verbosity*/ None, + &Some(schema.clone()), + /*output_schema_strict*/ false, + ) + .expect("text controls"); + + let format = text_controls.format.expect("format field"); + assert!(!format.strict); + assert_eq!(format.schema, schema); +} + #[test] fn omits_text_when_not_set() { let input: Vec = vec![]; diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index f4575b26a0..e2934dc1cc 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -7,17 +7,37 @@ use super::X_CODEX_PARENT_THREAD_ID_HEADER; use super::X_CODEX_TURN_METADATA_HEADER; use super::X_CODEX_WINDOW_ID_HEADER; use super::X_OPENAI_SUBAGENT_HEADER; +use codex_api::ApiError; +use codex_api::ResponseEvent; use codex_app_server_protocol::AuthMode; use codex_model_provider::BearerAuthProvider; use codex_model_provider_info::WireApi; use codex_model_provider_info::create_oss_provider_with_base_url; use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_rollout_trace::ExecutionStatus; +use codex_rollout_trace::InferenceTraceAttempt; +use codex_rollout_trace::InferenceTraceContext; +use codex_rollout_trace::RawTraceEventPayload; +use codex_rollout_trace::RolloutTrace; +use codex_rollout_trace::TraceWriter; +use codex_rollout_trace::replay_bundle; +use futures::StreamExt; use pretty_assertions::assert_eq; use serde_json::json; +use std::collections::VecDeque; +use std::pin::Pin; +use std::sync::Arc; +use std::task::Context; +use std::task::Poll; +use std::time::Duration; +use tempfile::TempDir; +use tokio::sync::Notify; fn test_model_client(session_source: SessionSource) -> ModelClient { let provider = create_oss_provider_with_base_url("https://example.com/v1", WireApi::Responses); @@ -79,6 +99,92 @@ fn test_session_telemetry() -> SessionTelemetry { ) } +fn started_inference_attempt(temp: &TempDir) -> anyhow::Result { + let writer = Arc::new(TraceWriter::create( + temp.path(), + "trace-1".to_string(), + "rollout-1".to_string(), + "thread-root".to_string(), + )?); + writer.append(RawTraceEventPayload::ThreadStarted { + thread_id: "thread-root".to_string(), + agent_path: "/root".to_string(), + metadata_payload: None, + })?; + writer.append(RawTraceEventPayload::CodexTurnStarted { + codex_turn_id: "turn-1".to_string(), + thread_id: "thread-root".to_string(), + })?; + + let inference_trace = InferenceTraceContext::enabled( + writer, + "thread-root".to_string(), + "turn-1".to_string(), + "gpt-test".to_string(), + "test-provider".to_string(), + ); + let attempt = inference_trace.start_attempt(); + attempt.record_started(&json!({ + "model": "gpt-test", + "input": [{ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "hello"}] + }], + })); + Ok(attempt) +} + +fn output_message(id: &str, text: &str) -> ResponseItem { + ResponseItem::Message { + id: Some(id.to_string()), + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: text.to_string(), + }], + phase: None, + } +} + +async fn replay_until_cancelled(temp: &TempDir) -> anyhow::Result { + let mut rollout = replay_bundle(temp.path())?; + for _ in 0..50 { + let inference = rollout + .inference_calls + .values() + .next() + .expect("inference should be reduced"); + if inference.execution.status == ExecutionStatus::Cancelled { + return Ok(rollout); + } + tokio::time::sleep(Duration::from_millis(10)).await; + rollout = replay_bundle(temp.path())?; + } + Ok(rollout) +} + +struct NotifyAfterEventStream { + events: VecDeque, + yielded: usize, + notify_after: usize, + notify: Arc, +} + +impl futures::Stream for NotifyAfterEventStream { + type Item = std::result::Result; + + fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + let Some(event) = self.events.pop_front() else { + return Poll::Pending; + }; + self.yielded += 1; + if self.yielded == self.notify_after { + self.notify.notify_one(); + } + Poll::Ready(Some(Ok(event))) + } +} + #[test] fn build_subagent_headers_sets_other_subagent_label() { let client = test_model_client(SessionSource::SubAgent(SubAgentSource::Other( @@ -151,6 +257,91 @@ async fn summarize_memories_returns_empty_for_empty_input() { assert_eq!(output.len(), 0); } +#[tokio::test] +async fn dropped_response_stream_traces_cancelled_partial_output() -> anyhow::Result<()> { + let temp = TempDir::new()?; + let attempt = started_inference_attempt(&temp)?; + + // The provider has produced one complete output item, but no terminal + // response.completed event. The harness has enough information to keep this + // item in history, so the trace should preserve it when the stream is + // abandoned. + let item = output_message("msg-1", "partial answer"); + let api_stream = futures::stream::iter([Ok(ResponseEvent::OutputItemDone(item))]) + .chain(futures::stream::pending()); + let (mut stream, _) = super::map_response_stream(api_stream, test_session_telemetry(), attempt); + + let observed = stream + .next() + .await + .expect("mapped stream should yield output item")?; + assert!(matches!(observed, ResponseEvent::OutputItemDone(_))); + + // Dropping the consumer is how turn interruption/preemption stops polling + // the provider stream. The mapper task observes that drop asynchronously + // and records cancellation using the output items it has already seen. + drop(stream); + + // Cancellation is recorded by the mapper task after Drop wakes it, so the + // replay may need a short wait before the terminal event appears on disk. + let rollout = replay_until_cancelled(&temp).await?; + let inference = rollout + .inference_calls + .values() + .next() + .expect("inference should be reduced"); + + assert_eq!(inference.execution.status, ExecutionStatus::Cancelled); + assert_eq!(inference.response_item_ids.len(), 1); + assert_eq!(rollout.raw_payloads.len(), 2); + + Ok(()) +} + +#[tokio::test] +async fn dropped_backpressured_response_stream_traces_cancelled_partial_output() +-> anyhow::Result<()> { + let temp = TempDir::new()?; + let attempt = started_inference_attempt(&temp)?; + let backpressured_item_yielded = Arc::new(Notify::new()); + let mut events = VecDeque::new(); + for _ in 0..super::RESPONSE_STREAM_CHANNEL_CAPACITY { + events.push_back(ResponseEvent::Created); + } + events.push_back(ResponseEvent::OutputItemDone(output_message( + "msg-1", + "partial answer", + ))); + let api_stream = NotifyAfterEventStream { + events, + yielded: 0, + notify_after: super::RESPONSE_STREAM_CHANNEL_CAPACITY + 1, + notify: Arc::clone(&backpressured_item_yielded), + }; + + let (stream, _) = super::map_response_stream(api_stream, test_session_telemetry(), attempt); + + // Fill the mapper channel with non-terminal events, then yield one output + // item. The mapper has observed that item and is blocked trying to send it + // downstream, so dropping the consumer covers the send-failure path rather + // than the `consumer_dropped` select branch. + backpressured_item_yielded.notified().await; + drop(stream); + + let rollout = replay_until_cancelled(&temp).await?; + let inference = rollout + .inference_calls + .values() + .next() + .expect("inference should be reduced"); + + assert_eq!(inference.execution.status, ExecutionStatus::Cancelled); + assert_eq!(inference.response_item_ids.len(), 1); + assert_eq!(rollout.raw_payloads.len(), 2); + + Ok(()) +} + #[test] fn auth_request_telemetry_context_tracks_attached_auth_and_retry_phase() { let auth_context = AuthRequestTelemetryContext::new( diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 4f4ced4101..f3df3cd4c6 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use async_channel::Receiver; use async_channel::Sender; +use codex_analytics::GuardianApprovalRequestSource; use codex_async_utils::OrCancelExt; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::Event; @@ -46,8 +47,9 @@ use crate::session::SUBMISSION_CHANNEL_CAPACITY; use crate::session::emit_subagent_session_started; use crate::session::session::Session; use crate::session::turn_context::TurnContext; +use crate::session::turn_context::TurnEnvironment; use codex_login::AuthManager; -use codex_models_manager::manager::ModelsManager; +use codex_models_manager::manager::SharedModelsManager; use codex_protocol::error::CodexErr; use codex_protocol::protocol::InitialHistory; @@ -63,7 +65,7 @@ use crate::session::completed_session_loop_termination; pub(crate) async fn run_codex_thread_interactive( config: Config, auth_manager: Arc, - models_manager: Arc, + models_manager: SharedModelsManager, parent_session: Arc, parent_ctx: Arc, cancel_token: CancellationToken, @@ -72,7 +74,6 @@ pub(crate) async fn run_codex_thread_interactive( ) -> Result { let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_ops, rx_ops) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); - let CodexSpawnOk { codex, .. } = Box::pin(Codex::spawn(CodexSpawnArgs { config, auth_manager, @@ -91,22 +92,28 @@ pub(crate) async fn run_codex_thread_interactive( inherited_shell_snapshot: None, user_shell_override: None, inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), + parent_rollout_thread_trace: codex_rollout_trace::ThreadTraceContext::disabled(), parent_trace: None, + environments: parent_ctx + .environments + .iter() + .map(TurnEnvironment::selection) + .collect(), analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), + thread_store: Arc::clone(&parent_session.services.thread_store), })) - .await?; - if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { - let thread_config = codex.thread_config_snapshot().await; - let client_metadata = parent_session.app_server_client_metadata().await; - emit_subagent_session_started( - &parent_session.services.analytics_events_client, - client_metadata, - codex.session.conversation_id, - Some(parent_session.conversation_id), - thread_config, - subagent_source, - ); - } + .or_cancel(&cancel_token) + .await??; + let thread_config = codex.thread_config_snapshot().await; + let client_metadata = parent_session.app_server_client_metadata().await; + emit_subagent_session_started( + &parent_session.services.analytics_events_client, + client_metadata, + codex.session.conversation_id, + Some(parent_session.conversation_id), + thread_config, + subagent_source, + ); let codex = Arc::new(codex); // Use a child token so parent cancel cascades but we can scope it to this task @@ -156,7 +163,7 @@ pub(crate) async fn run_codex_thread_interactive( pub(crate) async fn run_codex_thread_one_shot( config: Config, auth_manager: Arc, - models_manager: Arc, + models_manager: SharedModelsManager, input: Vec, parent_session: Arc, parent_ctx: Arc, @@ -182,6 +189,7 @@ pub(crate) async fn run_codex_thread_one_shot( // Send the initial input to kick off the one-shot turn. io.submit(Op::UserInput { + environments: None, items: input, final_output_json_schema, responsesapi_client_metadata: None, @@ -470,6 +478,7 @@ async fn handle_exec_approval( justification: None, }, reason, + GuardianApprovalRequestSource::DelegatedSubagent, review_cancel.clone(), ); await_approval_with_cancel( @@ -572,6 +581,7 @@ async fn handle_patch_approval( patch, }, reason.clone(), + GuardianApprovalRequestSource::DelegatedSubagent, review_cancel.clone(), ); Some( @@ -688,6 +698,7 @@ async fn maybe_auto_review_mcp_request_user_input( new_guardian_review_id(), build_guardian_mcp_tool_review_request(&event.call_id, &invocation, metadata.as_ref()), /*retry_reason*/ None, + GuardianApprovalRequestSource::DelegatedSubagent, review_cancel.clone(), ); let decision = await_approval_with_cancel( @@ -798,6 +809,7 @@ where let empty = RequestPermissionsResponse { permissions: Default::default(), scope: PermissionGrantScope::Turn, + strict_auto_review: false, }; parent_session .notify_request_permissions_response(call_id, empty.clone()) @@ -807,6 +819,7 @@ where response = fut => response.unwrap_or_else(|| RequestPermissionsResponse { permissions: Default::default(), scope: PermissionGrantScope::Turn, + strict_auto_review: false, }), } } diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 3e685d92e9..84224ea2d5 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -153,6 +153,32 @@ async fn forward_ops_preserves_submission_trace_context() { .expect("forward_ops join error"); } +#[tokio::test] +async fn run_codex_thread_interactive_respects_pre_cancelled_spawn() { + let (parent_session, parent_ctx, _rx_events) = + crate::session::tests::make_session_and_context_with_rx().await; + let cancel_token = CancellationToken::new(); + cancel_token.cancel(); + + let result = timeout( + Duration::from_secs(/*secs*/ 1), + run_codex_thread_interactive( + parent_ctx.config.as_ref().clone(), + Arc::clone(&parent_session.services.auth_manager), + Arc::clone(&parent_session.services.models_manager), + parent_session, + parent_ctx, + cancel_token, + SubAgentSource::Review, + /*initial_history*/ None, + ), + ) + .await + .expect("cancelled delegate spawn should not hang"); + + assert!(matches!(result, Err(CodexErr::TurnAborted))); +} + #[tokio::test] async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { let (parent_session, parent_ctx, rx_events) = @@ -179,6 +205,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { ..RequestPermissionProfile::default() }, scope: PermissionGrantScope::Turn, + strict_auto_review: false, }; let delegated_cwd = parent_ctx.cwd.join("delegated-cwd"); let cancel_token = CancellationToken::new(); @@ -251,7 +278,7 @@ async fn handle_exec_approval_uses_call_id_for_guardian_review_and_approval_id_f crate::session::tests::make_session_and_context_with_rx().await; let mut parent_ctx = Arc::try_unwrap(parent_ctx).expect("single turn context ref"); let mut config = (*parent_ctx.config).clone(); - config.approvals_reviewer = ApprovalsReviewer::GuardianSubagent; + config.approvals_reviewer = ApprovalsReviewer::AutoReview; parent_ctx.config = Arc::new(config); parent_ctx .approval_policy @@ -363,7 +390,7 @@ async fn delegated_mcp_guardian_abort_returns_synthetic_decline_answer() { crate::session::tests::make_session_and_context_with_rx().await; let mut parent_ctx = Arc::try_unwrap(parent_ctx).expect("single turn context ref"); let mut config = (*parent_ctx.config).clone(); - config.approvals_reviewer = ApprovalsReviewer::GuardianSubagent; + config.approvals_reviewer = ApprovalsReviewer::AutoReview; parent_ctx.config = Arc::new(config); parent_ctx .approval_policy diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 6513f09d07..7454b98651 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -1,16 +1,22 @@ use crate::agent::AgentStatus; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use crate::goals::GoalRuntimeEvent; use crate::session::Codex; +use crate::session::SessionSettingsUpdate; use crate::session::SteerInputError; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; +use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::mcp::CallToolResult; use codex_protocol::models::ContentItem; +use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; @@ -42,6 +48,7 @@ pub struct ThreadConfigSnapshot { pub approval_policy: AskForApproval, pub approvals_reviewer: ApprovalsReviewer, pub sandbox_policy: SandboxPolicy, + pub permission_profile: PermissionProfile, pub cwd: AbsolutePathBuf, pub ephemeral: bool, pub reasoning_effort: Option, @@ -49,6 +56,23 @@ pub struct ThreadConfigSnapshot { pub session_source: SessionSource, } +/// Turn context overrides that app-server validates before starting a turn. +#[derive(Clone, Default)] +pub struct CodexThreadTurnContextOverrides { + pub cwd: Option, + pub approval_policy: Option, + pub approvals_reviewer: Option, + pub sandbox_policy: Option, + pub permission_profile: Option, + pub windows_sandbox_level: Option, + pub model: Option, + pub effort: Option>, + pub summary: Option, + pub service_tier: Option>, + pub collaboration_mode: Option, + pub personality: Option, +} + pub struct CodexThread { pub(crate) codex: Codex, rollout_path: Option, @@ -80,6 +104,53 @@ impl CodexThread { self.codex.shutdown_and_wait().await } + pub async fn apply_goal_resume_runtime_effects(&self) -> anyhow::Result<()> { + self.codex + .session + .goal_runtime_apply(GoalRuntimeEvent::ThreadResumed) + .await + } + + pub async fn continue_active_goal_if_idle(&self) -> anyhow::Result<()> { + self.codex + .session + .goal_runtime_apply(GoalRuntimeEvent::MaybeContinueIfIdle) + .await + } + + pub async fn prepare_external_goal_mutation(&self) { + if let Err(err) = self + .codex + .session + .goal_runtime_apply(GoalRuntimeEvent::ExternalMutationStarting) + .await + { + tracing::warn!("failed to prepare external goal mutation: {err}"); + } + } + + pub async fn apply_external_goal_set(&self, status: codex_state::ThreadGoalStatus) { + if let Err(err) = self + .codex + .session + .goal_runtime_apply(GoalRuntimeEvent::ExternalSet { status }) + .await + { + tracing::warn!("failed to apply external goal status runtime effects: {err}"); + } + } + + pub async fn apply_external_goal_clear(&self) { + if let Err(err) = self + .codex + .session + .goal_runtime_apply(GoalRuntimeEvent::ExternalClear) + .await + { + tracing::warn!("failed to apply external goal clear runtime effects: {err}"); + } + } + #[doc(hidden)] pub async fn ensure_rollout_materialized(&self) { self.codex.session.ensure_rollout_materialized().await; @@ -124,6 +195,51 @@ impl CodexThread { .await } + /// Validate persistent turn context overrides without committing them. + pub async fn validate_turn_context_overrides( + &self, + overrides: CodexThreadTurnContextOverrides, + ) -> ConstraintResult<()> { + let CodexThreadTurnContextOverrides { + cwd, + approval_policy, + approvals_reviewer, + sandbox_policy, + permission_profile, + windows_sandbox_level, + model, + effort, + summary, + service_tier, + collaboration_mode, + personality, + } = overrides; + let collaboration_mode = if let Some(collaboration_mode) = collaboration_mode { + collaboration_mode + } else { + self.codex + .session + .collaboration_mode() + .await + .with_updates(model, effort, /*developer_instructions*/ None) + }; + + let updates = SessionSettingsUpdate { + cwd, + approval_policy, + approvals_reviewer, + sandbox_policy, + permission_profile, + windows_sandbox_level, + collaboration_mode: Some(collaboration_mode), + reasoning_summary: summary, + service_tier, + personality, + ..Default::default() + }; + self.codex.session.validate_settings(&updates).await + } + /// Use sparingly: this is intended to be removed soon. pub async fn submit_with_id(&self, sub: Submission) -> CodexResult<()> { self.codex.submit_with_id(sub).await @@ -162,7 +278,6 @@ impl CodexThread { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { text: message }], - end_turn: None, phase: None, }; let pending_item = match pending_message_input_item(&message) { diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index c997e92bcf..1ebc307e33 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -18,7 +18,6 @@ use codex_analytics::CompactionStatus; use codex_analytics::CompactionStrategy; use codex_analytics::CompactionTrigger; use codex_analytics::now_unix_seconds; -use codex_features::Feature; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::items::ContextCompactionItem; @@ -31,6 +30,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; +use codex_rollout_trace::InferenceTraceContext; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::approx_token_count; use codex_utils_output_truncation::truncate_text; @@ -265,12 +265,6 @@ async fn run_compact_task_inner_impl( new_history = insert_initial_context_before_last_real_user_or_summary(new_history, initial_context); } - let ghost_snapshots: Vec = history_items - .iter() - .filter(|item| matches!(item, ResponseItem::GhostSnapshot { .. })) - .cloned() - .collect(); - new_history.extend(ghost_snapshots); let reference_context_item = match initial_context_injection { InitialContextInjection::DoNotInject => None, InitialContextInjection::BeforeLastUserMessage => Some(turn_context.to_turn_context_item()), @@ -294,7 +288,6 @@ async fn run_compact_task_inner_impl( } pub(crate) struct CompactionAnalyticsAttempt { - enabled: bool, thread_id: String, turn_id: String, trigger: CompactionTrigger, @@ -315,10 +308,8 @@ impl CompactionAnalyticsAttempt { implementation: CompactionImplementation, phase: CompactionPhase, ) -> Self { - let enabled = sess.enabled(Feature::GeneralAnalytics); let active_context_tokens_before = sess.get_total_token_usage().await; Self { - enabled, thread_id: sess.conversation_id.to_string(), turn_id: turn_context.sub_id.clone(), trigger, @@ -337,9 +328,6 @@ impl CompactionAnalyticsAttempt { status: CompactionStatus, error: Option, ) { - if !self.enabled { - return; - } let active_context_tokens_after = sess.get_total_token_usage().await; sess.services .analytics_events_client @@ -508,7 +496,6 @@ fn build_compacted_history_with_limit( content: vec![ContentItem::InputText { text: message.clone(), }], - end_turn: None, phase: None, }); } @@ -523,7 +510,6 @@ fn build_compacted_history_with_limit( id: None, role: "user".to_string(), content: vec![ContentItem::InputText { text: summary_text }], - end_turn: None, phase: None, }); @@ -546,6 +532,9 @@ async fn drain_to_completed( turn_context.reasoning_summary, turn_context.config.service_tier, turn_metadata_header, + // Rollout tracing currently models remote compaction only; local compaction streams + // are left untraced until the reducer has a first-class local compaction lifecycle. + &InferenceTraceContext::disabled(), ) .await?; loop { diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index e56812eca3..d8adb20772 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -26,6 +26,7 @@ use codex_protocol::models::ResponseItem; use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::TurnStartedEvent; +use codex_rollout_trace::CompactionCheckpointTracePayload; use futures::TryFutureExt; use tokio_util::sync::CancellationToken; use tracing::error; @@ -114,7 +115,16 @@ async fn run_remote_compact_task_inner_impl( turn_context: &Arc, initial_context_injection: InitialContextInjection, ) -> CodexResult<()> { - let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); + let context_compaction_item = ContextCompactionItem::new(); + // Use the UI compaction item ID as the trace compaction ID so protocol lifecycle events, + // endpoint attempts, and the installed history checkpoint all have one join key. + let compaction_trace = sess.services.rollout_thread_trace.compaction_trace_context( + turn_context.sub_id.as_str(), + context_compaction_item.id.as_str(), + turn_context.model_info.slug.as_str(), + turn_context.provider.info().name.as_str(), + ); + let compaction_item = TurnItem::ContextCompaction(context_compaction_item); sess.emit_turn_item_started(turn_context, &compaction_item) .await; let mut history = sess.clone_history().await; @@ -131,14 +141,10 @@ async fn run_remote_compact_task_inner_impl( "trimmed history items before remote compaction" ); } - // Required to keep `/undo` available after compaction - let ghost_snapshots: Vec = history - .raw_items() - .iter() - .filter(|item| matches!(item, ResponseItem::GhostSnapshot { .. })) - .cloned() - .collect(); - + // This is the history selected for remote compaction, after any trimming required to fit the + // compact endpoint. The checkpoint below records it separately from the next sampling request, + // whose prompt will repeat current developer/context prefix items. + let trace_input_history = history.raw_items().to_vec(); let prompt_input = history.for_prompt(&turn_context.model_info.input_modalities); let tool_router = built_tools( sess.as_ref(), @@ -156,8 +162,8 @@ async fn run_remote_compact_task_inner_impl( base_instructions, personality: turn_context.personality, output_schema: None, + output_schema_strict: true, }; - let mut new_history = sess .services .model_client @@ -167,6 +173,7 @@ async fn run_remote_compact_task_inner_impl( turn_context.reasoning_effort, turn_context.reasoning_summary, &turn_context.session_telemetry, + &compaction_trace, ) .or_else(|err| async { let total_usage_breakdown = sess.get_total_token_usage_breakdown().await; @@ -189,9 +196,6 @@ async fn run_remote_compact_task_inner_impl( ) .await; - if !ghost_snapshots.is_empty() { - new_history.extend(ghost_snapshots); - } let reference_context_item = match initial_context_injection { InitialContextInjection::DoNotInject => None, InitialContextInjection::BeforeLastUserMessage => Some(turn_context.to_turn_context_item()), @@ -200,6 +204,13 @@ async fn run_remote_compact_task_inner_impl( message: String::new(), replacement_history: Some(new_history.clone()), }; + // Install is the semantic boundary where the compact endpoint's output becomes live + // thread history. Keep it distinct from the later inference request so the reducer can + // still represent repeated developer/context prefix items exactly as the model saw them. + compaction_trace.record_installed(&CompactionCheckpointTracePayload { + input_history: &trace_input_history, + replacement_history: &new_history, + }); sess.replace_compacted_history(new_history, reference_context_item, compacted_item) .await; sess.recompute_token_usage(turn_context).await; @@ -268,7 +279,6 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } - | ResponseItem::GhostSnapshot { .. } | ResponseItem::Other => false, } } diff --git a/codex-rs/core/src/compact_tests.rs b/codex-rs/core/src/compact_tests.rs index fbdfdb051d..8fdb7fb4b2 100644 --- a/codex-rs/core/src/compact_tests.rs +++ b/codex-rs/core/src/compact_tests.rs @@ -63,7 +63,6 @@ fn collect_user_messages_extracts_user_text_only() { content: vec![ContentItem::OutputText { text: "ignored".to_string(), }], - end_turn: None, phase: None, }, ResponseItem::Message { @@ -72,7 +71,6 @@ fn collect_user_messages_extracts_user_text_only() { content: vec![ContentItem::InputText { text: "first".to_string(), }], - end_turn: None, phase: None, }, ResponseItem::Other, @@ -97,7 +95,6 @@ do things "# .to_string(), }], - end_turn: None, phase: None, }, ResponseItem::Message { @@ -106,7 +103,6 @@ do things content: vec![ContentItem::InputText { text: "cwd=/tmp".to_string(), }], - end_turn: None, phase: None, }, ResponseItem::Message { @@ -115,7 +111,6 @@ do things content: vec![ContentItem::InputText { text: "real user message".to_string(), }], - end_turn: None, phase: None, }, ]; @@ -223,7 +218,6 @@ async fn process_compacted_history_replaces_developer_messages() { content: vec![ContentItem::InputText { text: "stale permissions".to_string(), }], - end_turn: None, phase: None, }, ResponseItem::Message { @@ -232,7 +226,6 @@ async fn process_compacted_history_replaces_developer_messages() { content: vec![ContentItem::InputText { text: "summary".to_string(), }], - end_turn: None, phase: None, }, ResponseItem::Message { @@ -241,7 +234,6 @@ async fn process_compacted_history_replaces_developer_messages() { content: vec![ContentItem::InputText { text: "stale personality".to_string(), }], - end_turn: None, phase: None, }, ]; @@ -256,7 +248,6 @@ async fn process_compacted_history_replaces_developer_messages() { content: vec![ContentItem::InputText { text: "summary".to_string(), }], - end_turn: None, phase: None, }); assert_eq!(refreshed, expected); @@ -270,7 +261,6 @@ async fn process_compacted_history_reinjects_full_initial_context() { content: vec![ContentItem::InputText { text: "summary".to_string(), }], - end_turn: None, phase: None, }]; let (refreshed, mut expected) = process_compacted_history_with_test_session( @@ -284,7 +274,6 @@ async fn process_compacted_history_reinjects_full_initial_context() { content: vec![ContentItem::InputText { text: "summary".to_string(), }], - end_turn: None, phase: None, }); assert_eq!(refreshed, expected); @@ -304,7 +293,6 @@ keep me updated "# .to_string(), }], - end_turn: None, phase: None, }, ResponseItem::Message { @@ -317,7 +305,6 @@ keep me updated "# .to_string(), }], - end_turn: None, phase: None, }, ResponseItem::Message { @@ -330,7 +317,6 @@ keep me updated "# .to_string(), }], - end_turn: None, phase: None, }, ResponseItem::Message { @@ -339,7 +325,6 @@ keep me updated content: vec![ContentItem::InputText { text: "summary".to_string(), }], - end_turn: None, phase: None, }, ResponseItem::Message { @@ -348,7 +333,6 @@ keep me updated content: vec![ContentItem::InputText { text: "stale developer instructions".to_string(), }], - end_turn: None, phase: None, }, ]; @@ -363,7 +347,6 @@ keep me updated content: vec![ContentItem::InputText { text: "summary".to_string(), }], - end_turn: None, phase: None, }); assert_eq!(refreshed, expected); @@ -378,7 +361,6 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message content: vec![ContentItem::InputText { text: "older user".to_string(), }], - end_turn: None, phase: None, }, ResponseItem::Message { @@ -387,7 +369,6 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message content: vec![ContentItem::InputText { text: format!("{SUMMARY_PREFIX}\nsummary text"), }], - end_turn: None, phase: None, }, ResponseItem::Message { @@ -396,7 +377,6 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message content: vec![ContentItem::InputText { text: "latest user".to_string(), }], - end_turn: None, phase: None, }, ]; @@ -413,7 +393,6 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message content: vec![ContentItem::InputText { text: "older user".to_string(), }], - end_turn: None, phase: None, }, ResponseItem::Message { @@ -422,7 +401,6 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message content: vec![ContentItem::InputText { text: format!("{SUMMARY_PREFIX}\nsummary text"), }], - end_turn: None, phase: None, }, ]; @@ -433,7 +411,6 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message content: vec![ContentItem::InputText { text: "latest user".to_string(), }], - end_turn: None, phase: None, }); assert_eq!(refreshed, expected); @@ -447,7 +424,6 @@ async fn process_compacted_history_reinjects_model_switch_message() { content: vec![ContentItem::InputText { text: "summary".to_string(), }], - end_turn: None, phase: None, }]; let previous_turn_settings = PreviousTurnSettings { @@ -477,7 +453,6 @@ async fn process_compacted_history_reinjects_model_switch_message() { content: vec![ContentItem::InputText { text: "summary".to_string(), }], - end_turn: None, phase: None, }); assert_eq!(refreshed, expected); @@ -492,7 +467,6 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() content: vec![ContentItem::InputText { text: "older user".to_string(), }], - end_turn: None, phase: None, }, ResponseItem::Message { @@ -501,7 +475,6 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() content: vec![ContentItem::InputText { text: "latest user".to_string(), }], - end_turn: None, phase: None, }, ResponseItem::Message { @@ -510,7 +483,6 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() content: vec![ContentItem::InputText { text: format!("{SUMMARY_PREFIX}\nsummary text"), }], - end_turn: None, phase: None, }, ]; @@ -520,7 +492,6 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() content: vec![ContentItem::InputText { text: "fresh permissions".to_string(), }], - end_turn: None, phase: None, }]; @@ -533,7 +504,6 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() content: vec![ContentItem::InputText { text: "older user".to_string(), }], - end_turn: None, phase: None, }, ResponseItem::Message { @@ -542,7 +512,6 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() content: vec![ContentItem::InputText { text: "fresh permissions".to_string(), }], - end_turn: None, phase: None, }, ResponseItem::Message { @@ -551,7 +520,6 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() content: vec![ContentItem::InputText { text: "latest user".to_string(), }], - end_turn: None, phase: None, }, ResponseItem::Message { @@ -560,7 +528,6 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() content: vec![ContentItem::InputText { text: format!("{SUMMARY_PREFIX}\nsummary text"), }], - end_turn: None, phase: None, }, ]; @@ -578,7 +545,6 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_compaction_last content: vec![ContentItem::InputText { text: "fresh permissions".to_string(), }], - end_turn: None, phase: None, }]; @@ -591,7 +557,6 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_compaction_last content: vec![ContentItem::InputText { text: "fresh permissions".to_string(), }], - end_turn: None, phase: None, }, ResponseItem::Compaction { diff --git a/codex-rs/core/src/config/agent_roles.rs b/codex-rs/core/src/config/agent_roles.rs index b1d28cf838..abdef33e7d 100644 --- a/codex-rs/core/src/config/agent_roles.rs +++ b/codex-rs/core/src/config/agent_roles.rs @@ -1,6 +1,6 @@ use super::AgentRoleConfig; -use crate::config_loader::ConfigLayerStack; -use crate::config_loader::ConfigLayerStackOrdering; +use codex_config::ConfigLayerStack; +use codex_config::ConfigLayerStackOrdering; use codex_config::config_toml::AgentRoleToml; use codex_config::config_toml::AgentsToml; use codex_config::config_toml::ConfigToml; @@ -33,7 +33,8 @@ pub(crate) async fn load_agent_roles( for layer in layers { let mut layer_roles: BTreeMap = BTreeMap::new(); let mut declared_role_files = BTreeSet::new(); - let agents_toml = match agents_toml_from_layer(&layer.config) { + let config_folder = layer.config_folder(); + let agents_toml = match agents_toml_from_layer(&layer.config, config_folder.as_deref()) { Ok(agents_toml) => agents_toml, Err(err) => { push_agent_role_warning(startup_warnings, err); @@ -169,11 +170,16 @@ fn merge_missing_role_fields(role: &mut AgentRoleConfig, fallback: &AgentRoleCon .or(fallback.nickname_candidates.clone()); } -fn agents_toml_from_layer(layer_toml: &TomlValue) -> std::io::Result> { +fn agents_toml_from_layer( + layer_toml: &TomlValue, + config_base_dir: Option<&Path>, +) -> std::io::Result> { let Some(agents_toml) = layer_toml.get("agents") else { return Ok(None); }; + // AbsolutePathBufGuard resolves relative paths while it remains in scope. + let _guard = config_base_dir.map(AbsolutePathBufGuard::new); agents_toml .clone() .try_into() diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config/config_loader_tests.rs similarity index 91% rename from codex-rs/core/src/config_loader/tests.rs rename to codex-rs/core/src/config/config_loader_tests.rs index 337960f1b9..cc465d42b1 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -1,28 +1,33 @@ -use super::LoaderOverrides; -use super::load_config_layers_state; use crate::config::ConfigBuilder; use crate::config::ConfigOverrides; use crate::config::ConstraintError; -use crate::config_loader::CloudRequirementsLoadError; -use crate::config_loader::CloudRequirementsLoader; -use crate::config_loader::ConfigLayerEntry; -use crate::config_loader::ConfigLoadError; -use crate::config_loader::ConfigRequirements; -use crate::config_loader::ConfigRequirementsToml; -use crate::config_loader::ConfigRequirementsWithSources; -use crate::config_loader::FilesystemDenyReadPattern; -use crate::config_loader::RequirementSource; -use crate::config_loader::load_requirements_toml; -use crate::config_loader::version_for_toml; +use codex_app_server_protocol::ConfigLayerSource; use codex_config::CONFIG_TOML_FILE; +use codex_config::CloudRequirementsLoadError; +use codex_config::CloudRequirementsLoader; +use codex_config::ConfigError; +use codex_config::ConfigLayerEntry; +use codex_config::ConfigLayerStackOrdering; +use codex_config::ConfigLoadError; +use codex_config::ConfigRequirements; +use codex_config::ConfigRequirementsToml; +use codex_config::ConfigRequirementsWithSources; +use codex_config::FilesystemDenyReadPattern; +use codex_config::LoaderOverrides; +use codex_config::RequirementSource; use codex_config::SessionThreadConfig; use codex_config::StaticThreadConfigLoader; use codex_config::ThreadConfigSource; +use codex_config::config_error_from_toml; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; +use codex_config::loader::load_config_layers_state; +use codex_config::loader::load_requirements_toml; +use codex_config::version_for_toml; use codex_exec_server::LOCAL_FS; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WebSearchMode; +use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; @@ -33,7 +38,7 @@ use std::path::Path; use tempfile::tempdir; use toml::Value as TomlValue; -fn config_error_from_io(err: &std::io::Error) -> &super::ConfigError { +fn config_error_from_io(err: &std::io::Error) -> &ConfigError { err.get_ref() .and_then(|err| err.downcast_ref::()) .map(ConfigLoadError::config_error) @@ -103,15 +108,13 @@ async fn returns_config_error_for_invalid_user_config_toml() { LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await .expect_err("expected error"); let config_error = config_error_from_io(&err); let expected_toml_error = toml::from_str::(contents).expect_err("parse error"); - let expected_config_error = - super::config_error_from_toml(&config_path, contents, expected_toml_error); + let expected_config_error = config_error_from_toml(&config_path, contents, expected_toml_error); assert_eq!(config_error, &expected_config_error); } @@ -136,7 +139,6 @@ async fn ignore_user_config_keeps_empty_user_layer() -> std::io::Result<()> { }, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; @@ -168,7 +170,6 @@ async fn ignore_rules_marks_config_stack_for_exec_policy_rule_skip() -> std::io: }, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; @@ -194,7 +195,6 @@ async fn returns_config_error_for_invalid_managed_config_toml() { overrides, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await .expect_err("expected error"); @@ -202,7 +202,7 @@ async fn returns_config_error_for_invalid_managed_config_toml() { let config_error = config_error_from_io(&err); let expected_toml_error = toml::from_str::(contents).expect_err("parse error"); let expected_config_error = - super::config_error_from_toml(&managed_path, contents, expected_toml_error); + config_error_from_toml(&managed_path, contents, expected_toml_error); assert_eq!(config_error, &expected_config_error); } @@ -281,7 +281,6 @@ extra = true overrides, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await .expect("load config"); @@ -316,7 +315,6 @@ async fn returns_empty_when_all_layers_missing() { overrides, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await .expect("load layers"); @@ -325,7 +323,7 @@ async fn returns_empty_when_all_layers_missing() { .expect("expected a user layer even when CODEX_HOME/config.toml does not exist"); assert_eq!( &ConfigLayerEntry { - name: super::ConfigLayerSource::User { + name: ConfigLayerSource::User { file: AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, tmp.path()) }, config: TomlValue::Table(toml::map::Map::new()), @@ -350,7 +348,7 @@ async fn returns_empty_when_all_layers_missing() { let num_system_layers = layers .layers_high_to_low() .iter() - .filter(|layer| matches!(layer.name, super::ConfigLayerSource::System { .. })) + .filter(|layer| matches!(layer.name, ConfigLayerSource::System { .. })) .count(); assert_eq!( num_system_layers, 1, @@ -374,18 +372,24 @@ async fn includes_thread_config_layers_in_stack() -> anyhow::Result<()> { let cwd_dir = tmp.path().join("project"); tokio::fs::create_dir_all(&cwd_dir).await?; let cwd = AbsolutePathBuf::from_absolute_path(&cwd_dir)?; + let overrides = LoaderOverrides::without_managed_config_for_tests(); + let expected_system_config = AbsolutePathBuf::from_absolute_path( + overrides + .system_config_path + .as_ref() + .expect("test overrides should include a system config path"), + )?; let layers = load_config_layers_state( LOCAL_FS.as_ref(), tmp.path(), Some(cwd), &[("features.plugins".to_string(), TomlValue::Boolean(true))], - LoaderOverrides::without_managed_config_for_tests(), + overrides, CloudRequirementsLoader::default(), &StaticThreadConfigLoader::new(vec![ThreadConfigSource::Session(SessionThreadConfig { features: BTreeMap::from([("plugins".to_string(), false)]), ..Default::default() })]), - /*host_name*/ None, ) .await?; @@ -397,13 +401,13 @@ async fn includes_thread_config_layers_in_stack() -> anyhow::Result<()> { assert_eq!( layer_sources, vec![ - super::ConfigLayerSource::SessionFlags, - super::ConfigLayerSource::SessionFlags, - super::ConfigLayerSource::User { + ConfigLayerSource::SessionFlags, + ConfigLayerSource::SessionFlags, + ConfigLayerSource::User { file: AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, tmp.path()), }, - super::ConfigLayerSource::System { - file: super::system_config_toml_file()?, + ConfigLayerSource::System { + file: expected_system_config, }, ] ); @@ -462,7 +466,6 @@ flag = false overrides, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await .expect("load config"); @@ -482,7 +485,7 @@ flag = false .find(|layer| { matches!( layer.name, - super::ConfigLayerSource::LegacyManagedConfigTomlFromMdm + ConfigLayerSource::LegacyManagedConfigTomlFromMdm ) }) .expect("mdm layer"); @@ -523,7 +526,7 @@ writable_roots = ["~/code"] .await?; let expected_root = AbsolutePathBuf::from_absolute_path(home.join("code"))?; - match config.permissions.sandbox_policy.get() { + match &config.legacy_sandbox_policy() { SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { assert_eq!( writable_roots @@ -566,7 +569,6 @@ allowed_sandbox_modes = ["read-only"] loader_overrides, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; @@ -575,8 +577,8 @@ allowed_sandbox_modes = ["read-only"] AskForApproval::Never ); assert_eq!( - *state.requirements().sandbox_policy.get(), - SandboxPolicy::new_read_only_policy() + state.requirements().permission_profile.get(), + &PermissionProfile::read_only() ); assert!( state @@ -588,14 +590,15 @@ allowed_sandbox_modes = ["read-only"] assert!( state .requirements() - .sandbox_policy - .can_set(&SandboxPolicy::WorkspaceWrite { - writable_roots: Vec::new(), - read_only_access: Default::default(), - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }) + .permission_profile + .can_set(&PermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + )) .is_err() ); @@ -630,7 +633,6 @@ allowed_approval_policies = ["never"] loader_overrides, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; @@ -672,7 +674,6 @@ personality = true LOCAL_FS.as_ref(), &mut config_requirements_toml, &requirements_file, - /*host_name*/ None, ) .await?; @@ -688,14 +689,14 @@ personality = true .allowed_web_search_modes .as_deref() .cloned(), - Some(vec![crate::config_loader::WebSearchModeRequirement::Cached]) + Some(vec![codex_config::WebSearchModeRequirement::Cached]) ); assert_eq!( config_requirements_toml .feature_requirements .as_ref() .map(|requirements| requirements.value.clone()), - Some(crate::config_loader::FeatureRequirementsToml { + Some(codex_config::FeatureRequirementsToml { entries: BTreeMap::from([("personality".to_string(), true)]), }) ); @@ -734,14 +735,14 @@ personality = true ); assert_eq!( config_requirements.enforce_residency.value(), - Some(crate::config_loader::ResidencyRequirement::Us) + Some(codex_config::ResidencyRequirement::Us) ); assert_eq!( config_requirements .feature_requirements .as_ref() .map(|requirements| requirements.value.clone()), - Some(crate::config_loader::FeatureRequirementsToml { + Some(codex_config::FeatureRequirementsToml { entries: BTreeMap::from([("personality".to_string(), true)]), }) ); @@ -777,6 +778,7 @@ allowed_approval_policies = ["on-request"] remote_sandbox_config: None, allowed_web_search_modes: None, feature_requirements: None, + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -787,7 +789,6 @@ allowed_approval_policies = ["on-request"] })) }), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; @@ -833,6 +834,7 @@ allowed_approval_policies = ["on-request"] remote_sandbox_config: None, allowed_web_search_modes: None, feature_requirements: None, + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -846,7 +848,6 @@ allowed_approval_policies = ["on-request"] LOCAL_FS.as_ref(), &mut config_requirements_toml, &AbsolutePathBuf::try_from(requirements_file)?, - /*host_name*/ None, ) .await?; @@ -876,7 +877,7 @@ async fn system_remote_sandbox_config_keeps_cloud_sandbox_modes() -> anyhow::Res &requirements_file, r#" [[remote_sandbox_config]] -hostname_patterns = ["runner-*.ci.example.com"] +hostname_patterns = ["*"] allowed_sandbox_modes = ["read-only", "workspace-write"] "#, ) @@ -896,15 +897,16 @@ allowed_sandbox_modes = ["read-only"] LOCAL_FS.as_ref(), &mut config_requirements_toml, &AbsolutePathBuf::try_from(requirements_file)?, - Some("runner-01.ci.example.com"), ) .await?; let config_requirements: ConfigRequirements = config_requirements_toml.try_into()?; assert_eq!( - config_requirements - .sandbox_policy - .can_set(&SandboxPolicy::new_workspace_write_policy()), + config_requirements.permission_profile.can_set( + &PermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::new_workspace_write_policy() + ) + ), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "WorkspaceWrite".into(), @@ -937,7 +939,6 @@ deny_read = ["./sensitive", "../shared/secret.txt"] LOCAL_FS.as_ref(), &mut config_requirements_toml, &requirements_file, - /*host_name*/ None, ) .await?; @@ -992,7 +993,6 @@ deny_read = ["./sensitive/**/*.txt"] LOCAL_FS.as_ref(), &mut config_requirements_toml, &requirements_file, - /*host_name*/ None, ) .await?; @@ -1041,6 +1041,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> remote_sandbox_config: None, allowed_web_search_modes: None, feature_requirements: None, + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -1060,7 +1061,6 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> LoaderOverrides::default(), cloud_requirements, &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; @@ -1084,6 +1084,61 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> Ok(()) } +#[tokio::test] +async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let managed_dir = tmp.path().join("managed-hooks"); + tokio::fs::create_dir_all(&managed_dir).await?; + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + + let requirements = ConfigRequirementsToml { + hooks: Some(codex_config::ManagedHooksRequirementsToml { + managed_dir: Some(managed_dir.clone()), + windows_managed_dir: None, + hooks: codex_config::HookEventsToml { + pre_tool_use: vec![codex_config::MatcherGroup { + matcher: Some("^Bash$".to_string()), + hooks: vec![codex_config::HookHandlerConfig::Command { + command: format!("python3 {}/pre.py", managed_dir.display()), + timeout_sec: Some(10), + r#async: false, + status_message: Some("checking".to_string()), + }], + }], + ..Default::default() + }, + }), + ..ConfigRequirementsToml::default() + }; + let expected = requirements.clone(); + let cloud_requirements = CloudRequirementsLoader::new(async move { Ok(Some(requirements)) }); + + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + cloud_requirements, + &codex_config::NoopThreadConfigLoader, + ) + .await?; + + assert_eq!(layers.requirements_toml().hooks, expected.hooks); + assert_eq!( + layers + .requirements() + .managed_hooks + .as_ref() + .map(|hooks| hooks.source.clone()), + Some(Some(RequirementSource::CloudRequirements)) + ); + + Ok(()) +} + #[tokio::test] async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow::Result<()> { let tmp = tempdir()?; @@ -1096,7 +1151,7 @@ async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow:: allowed_sandbox_modes = ["read-only"] [[remote_sandbox_config]] - hostname_patterns = ["runner-*.ci.example.com"] + hostname_patterns = ["*"] allowed_sandbox_modes = ["read-only", "workspace-write"] "#, )?; @@ -1109,22 +1164,23 @@ async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow:: LoaderOverrides::default(), cloud_requirements, &codex_config::NoopThreadConfigLoader, - Some("runner-01.ci.example.com"), ) .await?; assert_eq!( layers.requirements_toml().allowed_sandbox_modes, Some(vec![ - crate::config_loader::SandboxModeRequirement::ReadOnly, - crate::config_loader::SandboxModeRequirement::WorkspaceWrite, + codex_config::SandboxModeRequirement::ReadOnly, + codex_config::SandboxModeRequirement::WorkspaceWrite, ]) ); assert!( layers .requirements() - .sandbox_policy - .can_set(&SandboxPolicy::new_workspace_write_policy()) + .permission_profile + .can_set(&PermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::new_workspace_write_policy() + )) .is_ok() ); @@ -1152,7 +1208,6 @@ async fn load_config_layers_fails_when_cloud_requirements_loader_fails() -> anyh )) }), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await .expect_err("cloud requirements failure should fail closed"); @@ -1201,7 +1256,6 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> { LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; @@ -1209,7 +1263,7 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> { .layers_high_to_low() .into_iter() .filter_map(|layer| match &layer.name { - super::ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder), + ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder), _ => None, }) .collect(); @@ -1348,18 +1402,17 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; let project_layers: Vec<_> = layers .layers_high_to_low() .into_iter() - .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) + .filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. })) .collect(); assert_eq!( vec![&ConfigLayerEntry { - name: super::ConfigLayerSource::Project { + name: ConfigLayerSource::Project { dot_codex_folder: AbsolutePathBuf::from_absolute_path(project_root.join(".codex"))?, }, config: TomlValue::Table(toml::map::Map::new()), @@ -1390,17 +1443,16 @@ async fn codex_home_is_not_loaded_as_project_layer_from_home_dir() -> std::io::R LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; let project_layers: Vec<_> = layers .get_layers( - super::ConfigLayerStackOrdering::HighestPrecedenceFirst, + ConfigLayerStackOrdering::HighestPrecedenceFirst, /*include_disabled*/ true, ) .into_iter() - .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) + .filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. })) .collect(); let expected: Vec<&ConfigLayerEntry> = Vec::new(); assert_eq!(expected, project_layers); @@ -1449,23 +1501,22 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; let project_layers: Vec<_> = layers .get_layers( - super::ConfigLayerStackOrdering::HighestPrecedenceFirst, + ConfigLayerStackOrdering::HighestPrecedenceFirst, /*include_disabled*/ true, ) .into_iter() - .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) + .filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. })) .collect(); let child_config: TomlValue = toml::from_str("foo = \"child\"\n").expect("parse child config"); assert_eq!( vec![&ConfigLayerEntry { - name: super::ConfigLayerSource::Project { + name: ConfigLayerSource::Project { dot_codex_folder: AbsolutePathBuf::from_absolute_path(&nested_dot_codex)?, }, config: child_config.clone(), @@ -1522,16 +1573,15 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result< LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; let project_layers_untrusted: Vec<_> = layers_untrusted .get_layers( - super::ConfigLayerStackOrdering::HighestPrecedenceFirst, + ConfigLayerStackOrdering::HighestPrecedenceFirst, /*include_disabled*/ true, ) .into_iter() - .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) + .filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. })) .collect(); assert_eq!(project_layers_untrusted.len(), 1); assert!( @@ -1563,16 +1613,15 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result< LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; let project_layers_unknown: Vec<_> = layers_unknown .get_layers( - super::ConfigLayerStackOrdering::HighestPrecedenceFirst, + ConfigLayerStackOrdering::HighestPrecedenceFirst, /*include_disabled*/ true, ) .into_iter() - .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) + .filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. })) .collect(); assert_eq!(project_layers_unknown.len(), 1); assert!( @@ -1631,17 +1680,16 @@ async fn project_trust_does_not_match_configured_alias_for_canonical_cwd() -> st LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; let project_layers: Vec<_> = layers .get_layers( - super::ConfigLayerStackOrdering::HighestPrecedenceFirst, + ConfigLayerStackOrdering::HighestPrecedenceFirst, /*include_disabled*/ true, ) .into_iter() - .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) + .filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. })) .collect(); assert_eq!(project_layers.len(), 1); assert!( @@ -1786,16 +1834,15 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io:: LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; let project_layers: Vec<_> = layers .get_layers( - super::ConfigLayerStackOrdering::HighestPrecedenceFirst, + ConfigLayerStackOrdering::HighestPrecedenceFirst, /*include_disabled*/ true, ) .into_iter() - .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) + .filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. })) .collect(); assert_eq!( project_layers.len(), @@ -1856,16 +1903,15 @@ async fn project_layer_without_config_toml_is_disabled_when_untrusted_or_unknown LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; let project_layers: Vec<_> = layers .get_layers( - super::ConfigLayerStackOrdering::HighestPrecedenceFirst, + ConfigLayerStackOrdering::HighestPrecedenceFirst, /*include_disabled*/ true, ) .into_iter() - .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) + .filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. })) .collect(); assert_eq!( project_layers.len(), @@ -1918,7 +1964,6 @@ async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> std::io LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; @@ -1963,7 +2008,6 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<() LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; @@ -1971,7 +2015,7 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<() .layers_high_to_low() .into_iter() .filter_map(|layer| match &layer.name { - super::ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder), + ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder), _ => None, }) .collect(); @@ -1993,14 +2037,14 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<() } mod requirements_exec_policy_tests { - use crate::config_loader::ConfigLayerEntry; - use crate::config_loader::ConfigLayerStack; - use crate::config_loader::ConfigRequirements; - use crate::config_loader::ConfigRequirementsToml; - use crate::config_loader::ConfigRequirementsWithSources; - use crate::config_loader::RequirementSource; use crate::exec_policy::load_exec_policy; use codex_app_server_protocol::ConfigLayerSource; + use codex_config::ConfigLayerEntry; + use codex_config::ConfigLayerStack; + use codex_config::ConfigRequirements; + use codex_config::ConfigRequirementsToml; + use codex_config::ConfigRequirementsWithSources; + use codex_config::RequirementSource; use codex_config::RequirementsExecPolicyDecisionToml; use codex_config::RequirementsExecPolicyParseError; use codex_config::RequirementsExecPolicyPatternTokenToml; diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index e7c7aa31dd..21a86dcadd 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1,15 +1,16 @@ use crate::agents_md::DEFAULT_AGENTS_MD_FILENAME; use crate::agents_md::LOCAL_AGENTS_MD_FILENAME; +use crate::config::ThreadStoreConfig; use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; use crate::config::edit::apply_blocking; -use crate::config_loader::RequirementSource; -use crate::config_loader::project_trust_key; use crate::plugins::PluginsManager; use assert_matches::assert_matches; use codex_config::CONFIG_TOML_FILE; +use codex_config::RequirementSource; use codex_config::config_toml::AgentRoleToml; use codex_config::config_toml::AgentsToml; +use codex_config::config_toml::AutoReviewToml; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; use codex_config::config_toml::RealtimeAudioConfig; @@ -19,6 +20,7 @@ use codex_config::config_toml::RealtimeTransport; use codex_config::config_toml::RealtimeWsMode; use codex_config::config_toml::RealtimeWsVersion; use codex_config::config_toml::ToolsToml; +use codex_config::loader::project_trust_key; use codex_config::permissions_toml::FilesystemPermissionToml; use codex_config::permissions_toml::FilesystemPermissionsToml; use codex_config::permissions_toml::NetworkDomainPermissionToml; @@ -38,6 +40,7 @@ use codex_config::types::McpServerTransportConfig; use codex_config::types::MemoriesConfig; use codex_config::types::MemoriesToml; use codex_config::types::ModelAvailabilityNuxConfig; +use codex_config::types::Notice; use codex_config::types::NotificationCondition; use codex_config::types::NotificationMethod; use codex_config::types::Notifications; @@ -53,14 +56,18 @@ use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_model_provider_info::WireApi; use codex_models_manager::bundled_models_response; +use codex_protocol::models::ManagedFileSystemPermissions; +use codex_protocol::models::PermissionProfile; +use codex_protocol::models::SandboxEnforcement; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_protocol::protocol::ReadOnlyAccess; +use codex_protocol::protocol::NetworkAccess; use codex_protocol::protocol::RealtimeVoice; +use codex_protocol::protocol::SandboxPolicy; use serde::Deserialize; use tempfile::tempdir; @@ -126,6 +133,34 @@ fn http_mcp(url: &str) -> McpServerConfig { } } +async fn derive_legacy_sandbox_policy_for_test( + cfg: &ConfigToml, + sandbox_mode_override: Option, + profile_sandbox_mode: Option, + windows_sandbox_level: WindowsSandboxLevel, + active_project: Option<&ProjectConfig>, + permission_profile_constraint: Option<&Constrained>, +) -> SandboxPolicy { + let permission_profile = cfg + .derive_permission_profile( + sandbox_mode_override, + profile_sandbox_mode, + windows_sandbox_level, + active_project, + permission_profile_constraint, + ) + .await; + permission_profile + .to_legacy_sandbox_policy(Path::new("/")) + .unwrap_or_else(|err| { + tracing::warn!( + error = %err, + "derived permission profile cannot be represented as a legacy sandbox policy; falling back to read-only" + ); + SandboxPolicy::new_read_only_policy() + }) +} + #[tokio::test] async fn load_config_normalizes_relative_cwd_override() -> std::io::Result<()> { let expected_cwd = AbsolutePathBuf::relative_to_current_dir("nested")?; @@ -395,9 +430,10 @@ fn accepts_amazon_bedrock_aws_profile_override() { r#" [model_providers.amazon-bedrock.aws] profile = "codex-bedrock" +region = "us-west-2" "#, ) - .expect("Amazon Bedrock AWS profile override should deserialize"); + .expect("Amazon Bedrock AWS overrides should deserialize"); assert_eq!( cfg.model_providers @@ -406,6 +442,13 @@ profile = "codex-bedrock" .and_then(|aws| aws.profile.as_deref()), Some("codex-bedrock") ); + assert_eq!( + cfg.model_providers + .get("amazon-bedrock") + .and_then(|provider| provider.aws.as_ref()) + .and_then(|aws| aws.region.as_deref()), + Some("us-west-2") + ); } #[tokio::test] @@ -416,9 +459,10 @@ model_provider = "amazon-bedrock" [model_providers.amazon-bedrock.aws] profile = "codex-bedrock" +region = "us-west-2" "#, ) - .expect("Amazon Bedrock AWS profile override should deserialize"); + .expect("Amazon Bedrock AWS overrides should deserialize"); let config = Config::load_from_base_config_with_overrides( cfg, @@ -437,6 +481,14 @@ profile = "codex-bedrock" .and_then(|aws| aws.profile.as_deref()), Some("codex-bedrock") ); + assert_eq!( + config + .model_provider + .aws + .as_ref() + .and_then(|aws| aws.region.as_deref()), + Some("us-west-2") + ); } #[tokio::test] @@ -453,6 +505,7 @@ supports_websockets = true [model_providers.amazon-bedrock.aws] profile = "codex-bedrock" +region = "us-west-2" "#, ) .expect("Amazon Bedrock unsupported overrides should deserialize"); @@ -467,7 +520,7 @@ profile = "codex-bedrock" assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); assert!(err.to_string().contains( - "model_providers.amazon-bedrock only supports changing `aws.profile`; other non-default provider fields are not supported" + "model_providers.amazon-bedrock only supports changing `aws.profile` and `aws.region`; other non-default provider fields are not supported" )); } @@ -497,10 +550,28 @@ fn config_toml_deserializes_model_availability_nux() { ("gpt-foo".to_string(), 2), ]), }, + terminal_resize_reflow_max_rows: None, } ); } +#[test] +fn config_toml_deserializes_terminal_resize_reflow_config() { + let toml = r#" +[tui] +terminal_resize_reflow_max_rows = 9000 +"#; + let cfg: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for resize reflow config"); + + assert_eq!( + cfg.tui + .expect("tui config should deserialize") + .terminal_resize_reflow_max_rows, + Some(9000) + ); +} + #[tokio::test] async fn runtime_config_defaults_model_availability_nux() { let cfg = Config::load_from_base_config_with_overrides( @@ -734,7 +805,7 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: let memories_root = codex_home.path().join("memories").abs(); assert_eq!( - config.permissions.file_system_sandbox_policy, + config.permissions.file_system_sandbox_policy(), FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -763,25 +834,243 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: ]), ); assert_eq!( - config.permissions.sandbox_policy.get(), + &config.legacy_sandbox_policy(), &SandboxPolicy::WorkspaceWrite { writable_roots: vec![memories_root], - read_only_access: ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![cwd.path().join("docs").abs(),], - }, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, } ); assert_eq!( - config.permissions.network_sandbox_policy, + config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Restricted ); Ok(()) } +#[tokio::test] +async fn permission_profile_override_populates_runtime_permissions() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let permission_profile = PermissionProfile::Disabled; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + permission_profile: Some(permission_profile.clone()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + assert_eq!(config.permissions.permission_profile(), permission_profile); + assert_eq!( + &config.legacy_sandbox_policy(), + &SandboxPolicy::DangerFullAccess + ); + Ok(()) +} + +#[tokio::test] +async fn permission_profile_override_preserves_managed_unrestricted_filesystem() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let permission_profile = PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Unrestricted, + network: NetworkSandboxPolicy::Restricted, + }; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + permission_profile: Some(permission_profile.clone()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + assert_eq!(config.permissions.permission_profile(), permission_profile); + assert_eq!( + &config.legacy_sandbox_policy(), + &SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + } + ); + Ok(()) +} + +#[tokio::test] +async fn managed_unrestricted_permission_profile_still_enables_network_requirements() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let permission_profile = PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Unrestricted, + network: NetworkSandboxPolicy::Enabled, + }; + + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + permission_profile: Some(permission_profile), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + assert_eq!( + &config.legacy_sandbox_policy(), + &SandboxPolicy::DangerFullAccess, + "the legacy projection is intentionally lossy for managed unrestricted profiles" + ); + + let layers = config + .config_layer_stack + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) + .into_iter() + .cloned() + .collect(); + let mut requirements = config.config_layer_stack.requirements().clone(); + requirements.network = Some(Sourced::new( + codex_config::NetworkConstraints { + enabled: Some(true), + ..Default::default() + }, + RequirementSource::CloudRequirements, + )); + let mut requirements_toml = config.config_layer_stack.requirements_toml().clone(); + requirements_toml.network = Some(codex_config::NetworkRequirementsToml { + enabled: Some(true), + ..Default::default() + }); + config.config_layer_stack = ConfigLayerStack::new(layers, requirements, requirements_toml) + .expect("config layer stack with network requirements"); + + assert!(config.managed_network_requirements_enabled()); + Ok(()) +} + +#[tokio::test] +async fn permission_profile_override_applies_runtime_roots_to_legacy_projection() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let permission_profile = PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, + }, + ]), + NetworkSandboxPolicy::Restricted, + ); + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + permission_profile: Some(permission_profile), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + let memories_root = codex_home.path().join("memories").abs(); + assert!( + config + .permissions + .file_system_sandbox_policy() + .can_write_path_with_cwd(memories_root.as_path(), cwd.path()) + ); + assert_eq!( + &config.legacy_sandbox_policy(), + &SandboxPolicy::WorkspaceWrite { + writable_roots: vec![memories_root], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + } + ); + Ok(()) +} + +#[tokio::test] +async fn permission_profile_override_preserves_configured_network_proxy() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let permission_profile = PermissionProfile::Disabled; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some("workspace".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: None, + entries: BTreeMap::from([( + ":minimal".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + )]), + }), + network: Some(NetworkToml { + enabled: Some(true), + proxy_url: Some("http://127.0.0.1:43128".to_string()), + enable_socks5: Some(false), + allow_upstream_proxy: Some(false), + domains: Some(NetworkDomainPermissionsToml { + entries: BTreeMap::from([( + "openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + }), + ..Default::default() + }), + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + permission_profile: Some(permission_profile.clone()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + let network = config + .permissions + .network + .as_ref() + .expect("network-enabled override should preserve configured proxy"); + + assert_eq!(network.proxy_host_and_port(), "127.0.0.1:43128"); + assert!(!network.socks_enabled()); + assert_eq!(config.permissions.permission_profile(), permission_profile); + Ok(()) +} + #[tokio::test] async fn project_root_glob_none_compiles_to_filesystem_pattern_entry() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -822,7 +1111,7 @@ async fn project_root_glob_none_compiles_to_filesystem_pattern_entry() -> std::i assert_eq!( config .permissions - .file_system_sandbox_policy + .file_system_sandbox_policy() .glob_scan_max_depth, Some(2) ); @@ -832,7 +1121,7 @@ async fn project_root_glob_none_compiles_to_filesystem_pattern_entry() -> std::i assert!( config .permissions - .file_system_sandbox_policy + .file_system_sandbox_policy() .entries .contains(&FileSystemSandboxEntry { path: FileSystemPath::GlobPattern { @@ -844,7 +1133,7 @@ async fn project_root_glob_none_compiles_to_filesystem_pattern_entry() -> std::i assert!( !config .permissions - .file_system_sandbox_policy + .file_system_sandbox_policy() .entries .iter() .any(|entry| matches!( @@ -901,13 +1190,16 @@ async fn permissions_profiles_require_default_permissions() -> std::io::Result<( } #[tokio::test] -async fn permissions_profiles_reject_writes_outside_workspace_root() -> std::io::Result<()> { +async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() +-> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; - let external_write_path = if cfg!(windows) { r"C:\temp" } else { "/tmp" }; + let external_write_dir = TempDir::new()?; + let external_write_path = + AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(external_write_dir.path())?)?; - let err = Config::load_from_base_config_with_overrides( + let config = Config::load_from_base_config_with_overrides( ConfigToml { default_permissions: Some("workspace".to_string()), permissions: Some(PermissionsToml { @@ -917,7 +1209,7 @@ async fn permissions_profiles_reject_writes_outside_workspace_root() -> std::io: filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( - external_write_path.to_string(), + external_write_path.to_string_lossy().into_owned(), FilesystemPermissionToml::Access(FileSystemAccessMode::Write), )]), }), @@ -933,14 +1225,25 @@ async fn permissions_profiles_reject_writes_outside_workspace_root() -> std::io: }, codex_home.abs(), ) - .await - .expect_err("writes outside the workspace root should be rejected"); + .await?; - assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + let memories_root = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize( + codex_home.path().join("memories"), + )?)?; assert!( - err.to_string() - .contains("filesystem writes outside the workspace root"), - "{err}" + config + .permissions + .file_system_sandbox_policy() + .can_write_path_with_cwd(external_write_path.as_path(), cwd.path()) + ); + assert_eq!( + &config.legacy_sandbox_policy(), + &SandboxPolicy::WorkspaceWrite { + writable_roots: vec![external_write_path, memories_root], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + } ); Ok(()) } @@ -1030,7 +1333,7 @@ async fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<( .await?; assert_eq!( - config.permissions.file_system_sandbox_policy, + config.permissions.file_system_sandbox_policy(), FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::unknown( @@ -1042,12 +1345,8 @@ async fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<( }]), ); assert_eq!( - config.permissions.sandbox_policy.get(), + &config.legacy_sandbox_policy(), &SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: Vec::new(), - }, network_access: false, } ); @@ -1080,7 +1379,7 @@ async fn permissions_profiles_allow_unknown_special_paths_with_nested_entries() .await?; assert_eq!( - config.permissions.file_system_sandbox_policy, + config.permissions.file_system_sandbox_policy(), FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::unknown(":future_special_path", Some("docs".into())), @@ -1107,16 +1406,12 @@ async fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io .await?; assert_eq!( - config.permissions.file_system_sandbox_policy, + config.permissions.file_system_sandbox_policy(), FileSystemSandboxPolicy::restricted(Vec::new()) ); assert_eq!( - config.permissions.sandbox_policy.get(), + &config.legacy_sandbox_policy(), &SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: Vec::new(), - }, network_access: false, } ); @@ -1142,7 +1437,7 @@ async fn permissions_profiles_allow_empty_filesystem_with_warning() -> std::io:: .await?; assert_eq!( - config.permissions.file_system_sandbox_policy, + config.permissions.file_system_sandbox_policy(), FileSystemSandboxPolicy::restricted(Vec::new()) ); assert!( @@ -1239,16 +1534,10 @@ async fn permissions_profiles_allow_network_enablement() -> std::io::Result<()> .await?; assert!( - config.permissions.network_sandbox_policy.is_enabled(), + config.permissions.network_sandbox_policy().is_enabled(), "expected network sandbox policy to be enabled", ); - assert!( - config - .permissions - .sandbox_policy - .get() - .has_full_network_access() - ); + assert!(config.legacy_sandbox_policy().has_full_network_access()); Ok(()) } @@ -1295,10 +1584,69 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { terminal_title: None, theme: None, model_availability_nux: ModelAvailabilityNuxConfig::default(), + terminal_resize_reflow_max_rows: None, } ); } +#[tokio::test] +async fn runtime_config_resolves_terminal_resize_reflow_defaults_and_overrides() { + let cfg = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + tempdir().expect("tempdir").abs(), + ) + .await + .expect("load default config"); + + assert_eq!( + cfg.terminal_resize_reflow, + TerminalResizeReflowConfig::default() + ); + assert_eq!( + cfg.terminal_resize_reflow.max_rows, + TerminalResizeReflowMaxRows::Auto + ); + + let cfg = Config::load_from_base_config_with_overrides( + ConfigToml { + tui: Some(Tui { + terminal_resize_reflow_max_rows: Some(9000), + ..Default::default() + }), + ..Default::default() + }, + ConfigOverrides::default(), + tempdir().expect("tempdir").abs(), + ) + .await + .expect("load overridden config"); + + assert_eq!( + cfg.terminal_resize_reflow.max_rows, + TerminalResizeReflowMaxRows::Limit(9000) + ); + + let cfg = Config::load_from_base_config_with_overrides( + ConfigToml { + tui: Some(Tui { + terminal_resize_reflow_max_rows: Some(0), + ..Default::default() + }), + ..Default::default() + }, + ConfigOverrides::default(), + tempdir().expect("tempdir").abs(), + ) + .await + .expect("load config with disabled resize reflow limits"); + + assert_eq!( + cfg.terminal_resize_reflow.max_rows, + TerminalResizeReflowMaxRows::Disabled + ); +} + #[tokio::test] async fn test_sandbox_config_parsing() { let sandbox_full_access = r#" @@ -1310,15 +1658,15 @@ network_access = false # This should be ignored. let sandbox_full_access_cfg = toml::from_str::(sandbox_full_access) .expect("TOML deserialization should succeed"); let sandbox_mode_override = None; - let resolution = sandbox_full_access_cfg - .derive_sandbox_policy( - sandbox_mode_override, - /*profile_sandbox_mode*/ None, - WindowsSandboxLevel::Disabled, - /*active_project*/ None, - /*sandbox_policy_constraint*/ None, - ) - .await; + let resolution = derive_legacy_sandbox_policy_for_test( + &sandbox_full_access_cfg, + sandbox_mode_override, + /*profile_sandbox_mode*/ None, + WindowsSandboxLevel::Disabled, + /*active_project*/ None, + /*permission_profile_constraint*/ None, + ) + .await; assert_eq!(resolution, SandboxPolicy::DangerFullAccess); let sandbox_read_only = r#" @@ -1331,15 +1679,15 @@ network_access = true # This should be ignored. let sandbox_read_only_cfg = toml::from_str::(sandbox_read_only) .expect("TOML deserialization should succeed"); let sandbox_mode_override = None; - let resolution = sandbox_read_only_cfg - .derive_sandbox_policy( - sandbox_mode_override, - /*profile_sandbox_mode*/ None, - WindowsSandboxLevel::Disabled, - /*active_project*/ None, - /*sandbox_policy_constraint*/ None, - ) - .await; + let resolution = derive_legacy_sandbox_policy_for_test( + &sandbox_read_only_cfg, + sandbox_mode_override, + /*profile_sandbox_mode*/ None, + WindowsSandboxLevel::Disabled, + /*active_project*/ None, + /*permission_profile_constraint*/ None, + ) + .await; assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); let writable_root = test_absolute_path("/my/workspace"); @@ -1363,15 +1711,15 @@ trust_level = "trusted" let sandbox_workspace_write_cfg = toml::from_str::(&sandbox_workspace_write) .expect("TOML deserialization should succeed"); let sandbox_mode_override = None; - let resolution = sandbox_workspace_write_cfg - .derive_sandbox_policy( - sandbox_mode_override, - /*profile_sandbox_mode*/ None, - WindowsSandboxLevel::Disabled, - /*active_project*/ None, - /*sandbox_policy_constraint*/ None, - ) - .await; + let resolution = derive_legacy_sandbox_policy_for_test( + &sandbox_workspace_write_cfg, + sandbox_mode_override, + /*profile_sandbox_mode*/ None, + WindowsSandboxLevel::Disabled, + /*active_project*/ None, + /*permission_profile_constraint*/ None, + ) + .await; if cfg!(target_os = "windows") { assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); } else { @@ -1379,7 +1727,6 @@ trust_level = "trusted" resolution, SandboxPolicy::WorkspaceWrite { writable_roots: vec![writable_root.clone()], - read_only_access: ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1404,15 +1751,15 @@ exclude_slash_tmp = true let sandbox_workspace_write_cfg = toml::from_str::(&sandbox_workspace_write) .expect("TOML deserialization should succeed"); let sandbox_mode_override = None; - let resolution = sandbox_workspace_write_cfg - .derive_sandbox_policy( - sandbox_mode_override, - /*profile_sandbox_mode*/ None, - WindowsSandboxLevel::Disabled, - /*active_project*/ None, - /*sandbox_policy_constraint*/ None, - ) - .await; + let resolution = derive_legacy_sandbox_policy_for_test( + &sandbox_workspace_write_cfg, + sandbox_mode_override, + /*profile_sandbox_mode*/ None, + WindowsSandboxLevel::Disabled, + /*active_project*/ None, + /*permission_profile_constraint*/ None, + ) + .await; if cfg!(target_os = "windows") { assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); } else { @@ -1420,7 +1767,6 @@ exclude_slash_tmp = true resolution, SandboxPolicy::WorkspaceWrite { writable_roots: vec![writable_root], - read_only_access: ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1430,7 +1776,7 @@ exclude_slash_tmp = true } #[tokio::test] -async fn legacy_sandbox_mode_config_builds_split_policies_without_drift() -> std::io::Result<()> { +async fn legacy_sandbox_mode_builds_profiles_with_compatible_projection() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let extra_root = test_absolute_path("/tmp/legacy-extra-root"); @@ -1475,26 +1821,91 @@ exclude_slash_tmp = true ) .await?; - let sandbox_policy = config.permissions.sandbox_policy.get(); + let sandbox_policy = config.legacy_sandbox_policy(); + let file_system_policy = config.permissions.file_system_sandbox_policy(); + let network_policy = config.permissions.network_sandbox_policy(); + assert_eq!( - config.permissions.file_system_sandbox_policy, - FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, cwd.path()), - "case `{name}` should preserve filesystem semantics from legacy config" - ); - assert_eq!( - config.permissions.network_sandbox_policy, - NetworkSandboxPolicy::from(sandbox_policy), + network_policy, + NetworkSandboxPolicy::from(&sandbox_policy), "case `{name}` should preserve network semantics from legacy config" ); assert_eq!( - config - .permissions - .file_system_sandbox_policy - .to_legacy_sandbox_policy(config.permissions.network_sandbox_policy, cwd.path()) + file_system_policy + .to_legacy_sandbox_policy(network_policy, cwd.path()) .unwrap_or_else(|err| panic!("case `{name}` should round-trip: {err}")), - sandbox_policy.clone(), - "case `{name}` should round-trip through split policies without drift" + sandbox_policy, + "case `{name}` should preserve its legacy compatibility projection" ); + + match name.as_str() { + "danger-full-access" | "read-only" => { + assert_eq!( + file_system_policy, + FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( + &sandbox_policy, + cwd.path() + ), + "case `{name}` should match the legacy filesystem projection exactly" + ); + } + "workspace-write" => { + if cfg!(target_os = "windows") { + assert_eq!( + sandbox_policy, + SandboxPolicy::new_read_only_policy(), + "legacy workspace-write should keep the existing Windows downgrade when \ + the experimental Windows sandbox is disabled" + ); + assert_eq!( + file_system_policy, + FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( + &sandbox_policy, + cwd.path() + ), + "downgraded workspace-write should match the legacy read-only projection" + ); + continue; + } + assert!( + file_system_policy + .entries + .contains(&FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, + }) + ); + assert!( + file_system_policy + .entries + .contains(&FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: extra_root.clone(), + }, + access: FileSystemAccessMode::Write, + }) + ); + for subpath in [".git", ".agents", ".codex"] { + assert!( + file_system_policy + .entries + .contains(&FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(Some( + subpath.into() + )), + }, + access: FileSystemAccessMode::Read, + }), + "case `{name}` should preserve `{subpath}` as a symbolic project-root \ + metadata carveout" + ); + } + } + _ => unreachable!("unexpected test case `{name}`"), + } } Ok(()) @@ -1658,12 +2069,12 @@ async fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result< let expected_backend = backend.abs(); if cfg!(target_os = "windows") { - match config.permissions.sandbox_policy.get() { + match &config.legacy_sandbox_policy() { SandboxPolicy::ReadOnly { .. } => {} other => panic!("expected read-only policy on Windows, got {other:?}"), } } else { - match config.permissions.sandbox_policy.get() { + match &config.legacy_sandbox_policy() { SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { assert_eq!( writable_roots @@ -1721,7 +2132,7 @@ async fn workspace_write_always_includes_memories_root_once() -> std::io::Result .await?; if cfg!(target_os = "windows") { - match config.permissions.sandbox_policy.get() { + match &config.legacy_sandbox_policy() { SandboxPolicy::ReadOnly { .. } => {} other => panic!("expected read-only policy on Windows, got {other:?}"), } @@ -1732,7 +2143,7 @@ async fn workspace_write_always_includes_memories_root_once() -> std::io::Result memories_root.display() ); let expected_memories_root = memories_root.abs(); - match config.permissions.sandbox_policy.get() { + match &config.legacy_sandbox_policy() { SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { assert_eq!( writable_roots @@ -1935,24 +2346,25 @@ fn web_search_mode_disabled_overrides_legacy_request() { #[test] fn web_search_mode_for_turn_uses_preference_for_read_only() { let web_search_mode = Constrained::allow_any(WebSearchMode::Cached); - let mode = - resolve_web_search_mode_for_turn(&web_search_mode, &SandboxPolicy::new_read_only_policy()); + let permission_profile = + PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::new_read_only_policy()); + let mode = resolve_web_search_mode_for_turn(&web_search_mode, &permission_profile); assert_eq!(mode, WebSearchMode::Cached); } #[test] -fn web_search_mode_for_turn_prefers_live_for_danger_full_access() { +fn web_search_mode_for_turn_prefers_live_for_disabled_permissions() { let web_search_mode = Constrained::allow_any(WebSearchMode::Cached); - let mode = resolve_web_search_mode_for_turn(&web_search_mode, &SandboxPolicy::DangerFullAccess); + let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled); assert_eq!(mode, WebSearchMode::Live); } #[test] -fn web_search_mode_for_turn_respects_disabled_for_danger_full_access() { +fn web_search_mode_for_turn_respects_disabled_for_disabled_permissions() { let web_search_mode = Constrained::allow_any(WebSearchMode::Disabled); - let mode = resolve_web_search_mode_for_turn(&web_search_mode, &SandboxPolicy::DangerFullAccess); + let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled); assert_eq!(mode, WebSearchMode::Disabled); } @@ -1972,7 +2384,7 @@ fn web_search_mode_for_turn_falls_back_when_live_is_disallowed() -> anyhow::Resu }) } })?; - let mode = resolve_web_search_mode_for_turn(&web_search_mode, &SandboxPolicy::DangerFullAccess); + let mode = resolve_web_search_mode_for_turn(&web_search_mode, &PermissionProfile::Disabled); assert_eq!(mode, WebSearchMode::Cached); Ok(()) @@ -2050,7 +2462,7 @@ async fn profile_sandbox_mode_overrides_base() -> std::io::Result<()> { .await?; assert!(matches!( - config.permissions.sandbox_policy.get(), + &config.legacy_sandbox_policy(), &SandboxPolicy::DangerFullAccess )); @@ -2084,12 +2496,12 @@ async fn cli_override_takes_precedence_over_profile_sandbox_mode() -> std::io::R if cfg!(target_os = "windows") { assert!(matches!( - config.permissions.sandbox_policy.get(), + &config.legacy_sandbox_policy(), SandboxPolicy::ReadOnly { .. } )); } else { assert!(matches!( - config.permissions.sandbox_policy.get(), + &config.legacy_sandbox_policy(), SandboxPolicy::WorkspaceWrite { .. } )); } @@ -2213,7 +2625,6 @@ async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> { overrides, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; let cfg = @@ -2349,7 +2760,6 @@ async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> { overrides, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; @@ -3496,8 +3906,7 @@ async fn set_feature_enabled_updates_profile() -> anyhow::Result<()> { } #[tokio::test] -async fn set_feature_enabled_persists_default_false_feature_disable_in_profile() --> anyhow::Result<()> { +async fn set_feature_enabled_persists_feature_disable_in_profile() -> anyhow::Result<()> { let codex_home = TempDir::new()?; ConfigEditsBuilder::new(codex_home.path()) @@ -3659,7 +4068,7 @@ async fn load_config_uses_requirements_guardian_policy_config() -> std::io::Resu let config_layer_stack = ConfigLayerStack::new( Vec::new(), Default::default(), - crate::config_loader::ConfigRequirementsToml { + codex_config::ConfigRequirementsToml { guardian_policy_config: Some( " Use the workspace-managed guardian policy. ".to_string(), ), @@ -3688,13 +4097,123 @@ async fn load_config_uses_requirements_guardian_policy_config() -> std::io::Resu Ok(()) } +#[test] +fn config_toml_deserializes_auto_review_policy() { + let cfg = toml::from_str::( + r#" +[auto_review] +policy = "Use the user-configured guardian policy." +"#, + ) + .expect("TOML deserialization should succeed"); + + assert_eq!( + cfg.auto_review + .as_ref() + .and_then(|auto_review| auto_review.policy.as_deref()), + Some("Use the user-configured guardian policy.") + ); +} + +#[tokio::test] +async fn load_config_uses_auto_review_guardian_policy_config() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + auto_review: Some(AutoReviewToml { + policy: Some(" Use the user-configured guardian policy. ".to_string()), + }), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides { + cwd: Some(codex_home.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + assert_eq!( + config.guardian_policy_config.as_deref(), + Some("Use the user-configured guardian policy.") + ); + + Ok(()) +} + +#[tokio::test] +async fn requirements_guardian_policy_beats_auto_review() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let config_layer_stack = ConfigLayerStack::new( + Vec::new(), + Default::default(), + codex_config::ConfigRequirementsToml { + guardian_policy_config: Some("Use the managed guardian policy.".to_string()), + ..Default::default() + }, + ) + .map_err(std::io::Error::other)?; + let cfg = ConfigToml { + auto_review: Some(AutoReviewToml { + policy: Some("Use the user-configured guardian policy.".to_string()), + }), + ..Default::default() + }; + + let config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + cfg, + ConfigOverrides { + cwd: Some(codex_home.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + config_layer_stack, + ) + .await?; + + assert_eq!( + config.guardian_policy_config.as_deref(), + Some("Use the managed guardian policy.") + ); + + Ok(()) +} + +#[tokio::test] +async fn load_config_ignores_empty_auto_review_guardian_policy_config() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + auto_review: Some(AutoReviewToml { + policy: Some(" ".to_string()), + }), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides { + cwd: Some(codex_home.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + assert_eq!(config.guardian_policy_config, None); + + Ok(()) +} + #[tokio::test] async fn load_config_ignores_empty_requirements_guardian_policy_config() -> std::io::Result<()> { let codex_home = TempDir::new()?; let config_layer_stack = ConfigLayerStack::new( Vec::new(), Default::default(), - crate::config_loader::ConfigRequirementsToml { + codex_config::ConfigRequirementsToml { guardian_policy_config: Some(" ".to_string()), ..Default::default() }, @@ -3727,6 +4246,7 @@ async fn load_config_rejects_missing_agent_role_config_file() -> std::io::Result max_threads: None, max_depth: None, job_max_runtime_seconds: None, + interrupt_message: None, roles: BTreeMap::from([( "researcher".to_string(), AgentRoleToml { @@ -3803,6 +4323,63 @@ nickname_candidates = ["Hypatia", "Noether"] Ok(()) } +#[tokio::test] +async fn agent_role_relative_config_file_resolves_from_config_layer() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let role_config_path = codex_home.path().join("agents").join("researcher.toml"); + tokio::fs::create_dir_all( + role_config_path + .parent() + .expect("role config should have a parent directory"), + ) + .await?; + tokio::fs::write( + &role_config_path, + "developer_instructions = \"Research carefully\"\nmodel = \"gpt-5\"", + ) + .await?; + let layer_config = toml::from_str( + r#"[agents.researcher] +description = "Research role" +config_file = "./agents/researcher.toml" +"#, + ) + .expect("agent role layer config should parse"); + let config_layer_stack = codex_config::ConfigLayerStack::new( + vec![codex_config::ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::User { + file: codex_home.path().join(CONFIG_TOML_FILE).abs(), + }, + layer_config, + )], + Default::default(), + codex_config::ConfigRequirementsToml::default(), + ) + .map_err(std::io::Error::other)?; + + let config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + ConfigToml::default(), + ConfigOverrides { + cwd: Some(codex_home.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + config_layer_stack, + ) + .await?; + + assert_eq!( + config + .agent_roles + .get("researcher") + .and_then(|role| role.config_file.as_ref()), + Some(&role_config_path) + ); + + Ok(()) +} + #[tokio::test] async fn agent_role_file_metadata_overrides_config_toml_metadata() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -4585,6 +5162,29 @@ model = "gpt-5-mini" Ok(()) } +#[tokio::test] +async fn load_config_resolves_agent_interrupt_message() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + agents: Some(AgentsToml { + interrupt_message: Some(false), + ..Default::default() + }), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert!(!config.agent_interrupt_message_enabled); + + Ok(()) +} + #[tokio::test] async fn load_config_normalizes_agent_role_nickname_candidates() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -4593,6 +5193,7 @@ async fn load_config_normalizes_agent_role_nickname_candidates() -> std::io::Res max_threads: None, max_depth: None, job_max_runtime_seconds: None, + interrupt_message: None, roles: BTreeMap::from([( "researcher".to_string(), AgentRoleToml { @@ -4635,6 +5236,7 @@ async fn load_config_rejects_empty_agent_role_nickname_candidates() -> std::io:: max_threads: None, max_depth: None, job_max_runtime_seconds: None, + interrupt_message: None, roles: BTreeMap::from([( "researcher".to_string(), AgentRoleToml { @@ -4671,6 +5273,7 @@ async fn load_config_rejects_duplicate_agent_role_nickname_candidates() -> std:: max_threads: None, max_depth: None, job_max_runtime_seconds: None, + interrupt_message: None, roles: BTreeMap::from([( "researcher".to_string(), AgentRoleToml { @@ -4707,6 +5310,7 @@ async fn load_config_rejects_unsafe_agent_role_nickname_candidates() -> std::io: max_threads: None, max_depth: None, job_max_runtime_seconds: None, + interrupt_message: None, roles: BTreeMap::from([( "researcher".to_string(), AgentRoleToml { @@ -4930,11 +5534,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { model_provider: fixture.openai_provider.clone(), permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::Never), - sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), - file_system_sandbox_policy: FileSystemSandboxPolicy::from( - &SandboxPolicy::new_read_only_policy(), - ), - network_sandbox_policy: NetworkSandboxPolicy::Restricted, + permission_profile: Constrained::allow_any(PermissionProfile::read_only()), network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -4963,6 +5563,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { agent_roles: BTreeMap::new(), memories: MemoriesConfig::default(), agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS, + agent_interrupt_message_enabled: true, codex_home: fixture.codex_home(), sqlite_home: fixture.codex_home().to_path_buf(), log_dir: fixture.codex_home().join("log").to_path_buf(), @@ -4974,8 +5575,6 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { codex_self_exe: None, codex_linux_sandbox_exe: None, main_execve_wrapper_exe: None, - js_repl_node_path: None, - js_repl_node_module_dirs: Vec::new(), zsh_path: None, hide_agent_reasoning: false, show_raw_agent_reasoning: false, @@ -4994,7 +5593,8 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { realtime: RealtimeConfig::default(), experimental_realtime_ws_backend_prompt: None, experimental_realtime_ws_startup_context: None, - experimental_thread_store_endpoint: None, + experimental_thread_config_endpoint: None, + experimental_thread_store: ThreadStoreConfig::Local, base_instructions: None, developer_instructions: None, guardian_policy_config: None, @@ -5025,6 +5625,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), + terminal_resize_reflow: TerminalResizeReflowConfig::default(), analytics_enabled: Some(true), feedback_enabled: true, tool_suggest: ToolSuggestConfig::default(), @@ -5057,6 +5658,50 @@ async fn metrics_exporter_defaults_to_statsig_when_missing() -> std::io::Result< Ok(()) } +#[tokio::test] +async fn explicit_null_service_tier_override_sets_fast_default_opt_out() -> std::io::Result<()> { + let fixture = create_test_fixture()?; + + let config = Config::load_from_base_config_with_overrides( + fixture.cfg.clone(), + ConfigOverrides { + cwd: Some(fixture.cwd_path()), + service_tier: Some(None), + ..Default::default() + }, + fixture.codex_home(), + ) + .await?; + + assert_eq!(config.service_tier, None); + assert_eq!(config.notices.fast_default_opt_out, Some(true)); + Ok(()) +} + +#[tokio::test] +async fn fast_default_opt_out_notice_config_is_respected() -> std::io::Result<()> { + let fixture = create_test_fixture()?; + let mut cfg = fixture.cfg.clone(); + cfg.notice = Some(Notice { + fast_default_opt_out: Some(true), + ..Default::default() + }); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides { + cwd: Some(fixture.cwd_path()), + ..Default::default() + }, + fixture.codex_home(), + ) + .await?; + + assert_eq!(config.service_tier, None); + assert_eq!(config.notices.fast_default_opt_out, Some(true)); + Ok(()) +} + #[tokio::test] async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { let fixture = create_test_fixture()?; @@ -5082,11 +5727,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { model_provider: fixture.openai_custom_provider.clone(), permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted), - sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), - file_system_sandbox_policy: FileSystemSandboxPolicy::from( - &SandboxPolicy::new_read_only_policy(), - ), - network_sandbox_policy: NetworkSandboxPolicy::Restricted, + permission_profile: Constrained::allow_any(PermissionProfile::read_only()), network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -5115,6 +5756,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { agent_roles: BTreeMap::new(), memories: MemoriesConfig::default(), agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS, + agent_interrupt_message_enabled: true, codex_home: fixture.codex_home(), sqlite_home: fixture.codex_home().to_path_buf(), log_dir: fixture.codex_home().join("log").to_path_buf(), @@ -5126,8 +5768,6 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { codex_self_exe: None, codex_linux_sandbox_exe: None, main_execve_wrapper_exe: None, - js_repl_node_path: None, - js_repl_node_module_dirs: Vec::new(), zsh_path: None, hide_agent_reasoning: false, show_raw_agent_reasoning: false, @@ -5146,7 +5786,8 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { realtime: RealtimeConfig::default(), experimental_realtime_ws_backend_prompt: None, experimental_realtime_ws_startup_context: None, - experimental_thread_store_endpoint: None, + experimental_thread_config_endpoint: None, + experimental_thread_store: ThreadStoreConfig::Local, base_instructions: None, developer_instructions: None, guardian_policy_config: None, @@ -5177,6 +5818,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), + terminal_resize_reflow: TerminalResizeReflowConfig::default(), analytics_enabled: Some(true), feedback_enabled: true, tool_suggest: ToolSuggestConfig::default(), @@ -5232,11 +5874,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { model_provider: fixture.openai_provider.clone(), permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::OnFailure), - sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), - file_system_sandbox_policy: FileSystemSandboxPolicy::from( - &SandboxPolicy::new_read_only_policy(), - ), - network_sandbox_policy: NetworkSandboxPolicy::Restricted, + permission_profile: Constrained::allow_any(PermissionProfile::read_only()), network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -5265,6 +5903,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { agent_roles: BTreeMap::new(), memories: MemoriesConfig::default(), agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS, + agent_interrupt_message_enabled: true, codex_home: fixture.codex_home(), sqlite_home: fixture.codex_home().to_path_buf(), log_dir: fixture.codex_home().join("log").to_path_buf(), @@ -5276,8 +5915,6 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { codex_self_exe: None, codex_linux_sandbox_exe: None, main_execve_wrapper_exe: None, - js_repl_node_path: None, - js_repl_node_module_dirs: Vec::new(), zsh_path: None, hide_agent_reasoning: false, show_raw_agent_reasoning: false, @@ -5296,7 +5933,8 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { realtime: RealtimeConfig::default(), experimental_realtime_ws_backend_prompt: None, experimental_realtime_ws_startup_context: None, - experimental_thread_store_endpoint: None, + experimental_thread_config_endpoint: None, + experimental_thread_store: ThreadStoreConfig::Local, base_instructions: None, developer_instructions: None, guardian_policy_config: None, @@ -5327,6 +5965,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), + terminal_resize_reflow: TerminalResizeReflowConfig::default(), analytics_enabled: Some(false), feedback_enabled: true, tool_suggest: ToolSuggestConfig::default(), @@ -5367,11 +6006,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { model_provider: fixture.openai_provider.clone(), permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::OnFailure), - sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), - file_system_sandbox_policy: FileSystemSandboxPolicy::from( - &SandboxPolicy::new_read_only_policy(), - ), - network_sandbox_policy: NetworkSandboxPolicy::Restricted, + permission_profile: Constrained::allow_any(PermissionProfile::read_only()), network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -5400,6 +6035,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { agent_roles: BTreeMap::new(), memories: MemoriesConfig::default(), agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS, + agent_interrupt_message_enabled: true, codex_home: fixture.codex_home(), sqlite_home: fixture.codex_home().to_path_buf(), log_dir: fixture.codex_home().join("log").to_path_buf(), @@ -5411,8 +6047,6 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { codex_self_exe: None, codex_linux_sandbox_exe: None, main_execve_wrapper_exe: None, - js_repl_node_path: None, - js_repl_node_module_dirs: Vec::new(), zsh_path: None, hide_agent_reasoning: false, show_raw_agent_reasoning: false, @@ -5431,7 +6065,8 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { realtime: RealtimeConfig::default(), experimental_realtime_ws_backend_prompt: None, experimental_realtime_ws_startup_context: None, - experimental_thread_store_endpoint: None, + experimental_thread_config_endpoint: None, + experimental_thread_store: ThreadStoreConfig::Local, base_instructions: None, developer_instructions: None, guardian_policy_config: None, @@ -5462,6 +6097,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), + terminal_resize_reflow: TerminalResizeReflowConfig::default(), analytics_enabled: Some(true), feedback_enabled: true, tool_suggest: ToolSuggestConfig::default(), @@ -5482,15 +6118,14 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() { let fixture = create_test_fixture()?; - let requirements_toml = crate::config_loader::ConfigRequirementsToml { + let requirements_toml = codex_config::ConfigRequirementsToml { allowed_approval_policies: None, allowed_approvals_reviewers: None, allowed_sandbox_modes: None, remote_sandbox_config: None, - allowed_web_search_modes: Some(vec![ - crate::config_loader::WebSearchModeRequirement::Cached, - ]), + allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]), feature_requirements: None, + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -5499,7 +6134,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() permissions: None, guardian_policy_config: None, }; - let requirement_source = crate::config_loader::RequirementSource::Unknown; + let requirement_source = codex_config::RequirementSource::Unknown; let requirement_source_for_error = requirement_source.clone(); let allowed = vec![WebSearchMode::Disabled, WebSearchMode::Cached]; let constrained = Constrained::new(WebSearchMode::Cached, move |candidate| { @@ -5514,15 +6149,15 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() }) } })?; - let requirements = crate::config_loader::ConfigRequirements { - web_search_mode: crate::config_loader::ConstrainedWithSource::new( + let requirements = codex_config::ConfigRequirements { + web_search_mode: codex_config::ConstrainedWithSource::new( constrained, Some(requirement_source), ), ..Default::default() }; let config_layer_stack = - crate::config_loader::ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) + codex_config::ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) .expect("config layer stack"); let config = Config::load_config_with_layer_stack( @@ -5768,15 +6403,15 @@ trust_level = "untrusted" trust_level: Some(TrustLevel::Untrusted), }; - let resolution = cfg - .derive_sandbox_policy( - /*sandbox_mode_override*/ None, - /*profile_sandbox_mode*/ None, - WindowsSandboxLevel::Disabled, - Some(&active_project), - /*sandbox_policy_constraint*/ None, - ) - .await; + let resolution = derive_legacy_sandbox_policy_for_test( + &cfg, + /*sandbox_mode_override*/ None, + /*profile_sandbox_mode*/ None, + WindowsSandboxLevel::Disabled, + Some(&active_project), + /*permission_profile_constraint*/ None, + ) + .await; // Verify that untrusted projects get WorkspaceWrite (or ReadOnly on Windows due to downgrade) if cfg!(target_os = "windows") { @@ -5795,8 +6430,8 @@ trust_level = "untrusted" } #[tokio::test] -async fn derive_sandbox_policy_falls_back_to_constraint_value_for_implicit_defaults() --> anyhow::Result<()> { +async fn derive_sandbox_policy_falls_back_to_read_only_for_implicit_defaults() -> anyhow::Result<()> +{ let project_dir = TempDir::new()?; let project_path = project_dir.path().to_path_buf(); let project_key = project_path.to_string_lossy().to_string(); @@ -5812,30 +6447,30 @@ async fn derive_sandbox_policy_falls_back_to_constraint_value_for_implicit_defau let active_project = ProjectConfig { trust_level: Some(TrustLevel::Trusted), }; - let constrained = Constrained::new(SandboxPolicy::DangerFullAccess, |candidate| { - if matches!(candidate, SandboxPolicy::DangerFullAccess) { + let constrained = Constrained::new(PermissionProfile::read_only(), |candidate| { + if candidate == &PermissionProfile::read_only() { Ok(()) } else { Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: format!("{candidate:?}"), - allowed: "[DangerFullAccess]".to_string(), + allowed: "[ReadOnly]".to_string(), requirement_source: RequirementSource::Unknown, }) } })?; - let resolution = cfg - .derive_sandbox_policy( - /*sandbox_mode_override*/ None, - /*profile_sandbox_mode*/ None, - WindowsSandboxLevel::Disabled, - Some(&active_project), - Some(&constrained), - ) - .await; + let resolution = derive_legacy_sandbox_policy_for_test( + &cfg, + /*sandbox_mode_override*/ None, + /*profile_sandbox_mode*/ None, + WindowsSandboxLevel::Disabled, + Some(&active_project), + Some(&constrained), + ) + .await; - assert_eq!(resolution, SandboxPolicy::DangerFullAccess); + assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); Ok(()) } @@ -5857,28 +6492,39 @@ async fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallb let active_project = ProjectConfig { trust_level: Some(TrustLevel::Trusted), }; - let constrained = Constrained::new(SandboxPolicy::new_workspace_write_policy(), |candidate| { - if matches!(candidate, SandboxPolicy::WorkspaceWrite { .. }) { - Ok(()) - } else { - Err(ConstraintError::InvalidValue { - field_name: "sandbox_mode", - candidate: format!("{candidate:?}"), - allowed: "[WorkspaceWrite]".to_string(), - requirement_source: RequirementSource::Unknown, - }) - } - })?; + let constrained = Constrained::new( + PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + |candidate| { + if matches!( + candidate, + PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Restricted { entries, .. }, + .. + } if entries + .iter() + .any(|entry| entry.access.can_write()) + ) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: format!("{candidate:?}"), + allowed: "[WorkspaceWrite]".to_string(), + requirement_source: RequirementSource::Unknown, + }) + } + }, + )?; - let resolution = cfg - .derive_sandbox_policy( - /*sandbox_mode_override*/ None, - /*profile_sandbox_mode*/ None, - WindowsSandboxLevel::Disabled, - Some(&active_project), - Some(&constrained), - ) - .await; + let resolution = derive_legacy_sandbox_policy_for_test( + &cfg, + /*sandbox_mode_override*/ None, + /*profile_sandbox_mode*/ None, + WindowsSandboxLevel::Disabled, + Some(&active_project), + Some(&constrained), + ) + .await; if cfg!(target_os = "windows") { assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); @@ -6108,7 +6754,7 @@ async fn test_untrusted_project_gets_unless_trusted_approval_policy() -> anyhow: if cfg!(target_os = "windows") { assert!( matches!( - config.permissions.sandbox_policy.get(), + &config.legacy_sandbox_policy(), SandboxPolicy::ReadOnly { .. } ), "Expected ReadOnly on Windows" @@ -6116,7 +6762,7 @@ async fn test_untrusted_project_gets_unless_trusted_approval_policy() -> anyhow: } else { assert!( matches!( - config.permissions.sandbox_policy.get(), + &config.legacy_sandbox_policy(), SandboxPolicy::WorkspaceWrite { .. } ), "Expected WorkspaceWrite sandbox for untrusted project" @@ -6134,17 +6780,15 @@ async fn requirements_disallowing_default_sandbox_falls_back_to_required_default let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(crate::config_loader::ConfigRequirementsToml { - allowed_sandbox_modes: Some(vec![ - crate::config_loader::SandboxModeRequirement::ReadOnly, - ]), + Ok(Some(codex_config::ConfigRequirementsToml { + allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), ..Default::default() })) })) .build() .await?; assert_eq!( - *config.permissions.sandbox_policy.get(), + config.legacy_sandbox_policy(), SandboxPolicy::new_read_only_policy() ); Ok(()) @@ -6159,13 +6803,14 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s "#, )?; - let requirements = crate::config_loader::ConfigRequirementsToml { + let requirements = codex_config::ConfigRequirementsToml { allowed_approval_policies: None, allowed_approvals_reviewers: None, - allowed_sandbox_modes: Some(vec![crate::config_loader::SandboxModeRequirement::ReadOnly]), + allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), remote_sandbox_config: None, allowed_web_search_modes: None, feature_requirements: None, + hooks: None, mcp_servers: None, apps: None, rules: None, @@ -6184,12 +6829,99 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s .build() .await?; assert_eq!( - *config.permissions.sandbox_policy.get(), + config.legacy_sandbox_policy(), SandboxPolicy::new_read_only_policy() ); Ok(()) } +#[tokio::test] +async fn permission_profile_override_falls_back_when_disallowed_by_requirements() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + let requirements = codex_config::ConfigRequirementsToml { + allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), + ..Default::default() + }; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .harness_overrides(ConfigOverrides { + permission_profile: Some(PermissionProfile::Disabled), + ..Default::default() + }) + .cloud_requirements(CloudRequirementsLoader::new(async move { + Ok(Some(requirements)) + })) + .build() + .await?; + + let expected_sandbox_policy = SandboxPolicy::new_read_only_policy(); + assert_eq!(config.legacy_sandbox_policy(), expected_sandbox_policy); + assert_eq!( + config.permissions.permission_profile(), + PermissionProfile::read_only() + ); + Ok(()) +} + +#[tokio::test] +async fn permission_profile_override_preserves_split_write_roots() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = codex_home.path().join("workspace"); + let outside_root = codex_home.path().join("outside-write"); + std::fs::create_dir_all(&cwd)?; + std::fs::create_dir_all(&outside_root)?; + let outside_root = + AbsolutePathBuf::from_absolute_path(outside_root).expect("outside root is absolute"); + let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: outside_root.clone(), + }, + access: FileSystemAccessMode::Write, + }, + ]); + let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( + SandboxEnforcement::Managed, + &file_system_sandbox_policy, + NetworkSandboxPolicy::Restricted, + ); + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(cwd)) + .harness_overrides(ConfigOverrides { + permission_profile: Some(permission_profile), + ..Default::default() + }) + .build() + .await?; + + assert!( + config + .permissions + .file_system_sandbox_policy() + .can_write_path_with_cwd(outside_root.as_path(), config.cwd.as_path()) + ); + assert!(matches!( + &config.legacy_sandbox_policy(), + SandboxPolicy::WorkspaceWrite { .. } + )); + assert_eq!( + config.permissions.network_sandbox_policy(), + NetworkSandboxPolicy::Restricted + ); + Ok(()) +} + #[tokio::test] async fn requirements_web_search_mode_overrides_danger_full_access_default() -> std::io::Result<()> { @@ -6204,9 +6936,9 @@ async fn requirements_web_search_mode_overrides_danger_full_access_default() -> .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(crate::config_loader::ConfigRequirementsToml { + Ok(Some(codex_config::ConfigRequirementsToml { allowed_web_search_modes: Some(vec![ - crate::config_loader::WebSearchModeRequirement::Cached, + codex_config::WebSearchModeRequirement::Cached, ]), ..Default::default() })) @@ -6218,7 +6950,7 @@ async fn requirements_web_search_mode_overrides_danger_full_access_default() -> assert_eq!( resolve_web_search_mode_for_turn( &config.web_search_mode, - config.permissions.sandbox_policy.get(), + &config.permissions.permission_profile(), ), WebSearchMode::Cached, ); @@ -6245,7 +6977,7 @@ trust_level = "untrusted" .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(workspace.path().to_path_buf())) .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(crate::config_loader::ConfigRequirementsToml { + Ok(Some(codex_config::ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), ..Default::default() })) @@ -6274,7 +7006,7 @@ async fn explicit_approval_policy_falls_back_when_disallowed_by_requirements() - .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(crate::config_loader::ConfigRequirementsToml { + Ok(Some(codex_config::ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), ..Default::default() })) @@ -6295,8 +7027,8 @@ async fn feature_requirements_normalize_effective_feature_values() -> std::io::R let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(crate::config_loader::ConfigRequirementsToml { - feature_requirements: Some(crate::config_loader::FeatureRequirementsToml { + Ok(Some(codex_config::ConfigRequirementsToml { + feature_requirements: Some(codex_config::FeatureRequirementsToml { entries: BTreeMap::from([ ("personality".to_string(), true), ("shell_tool".to_string(), false), @@ -6322,6 +7054,54 @@ async fn feature_requirements_normalize_effective_feature_values() -> std::io::R Ok(()) } +#[tokio::test] +async fn feature_requirements_auto_review_disables_guardian_approval() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(codex_config::ConfigRequirementsToml { + feature_requirements: Some(codex_config::FeatureRequirementsToml { + entries: BTreeMap::from([("auto_review".to_string(), false)]), + }), + ..Default::default() + })) + })) + .build() + .await?; + + assert!(!config.features.enabled(Feature::GuardianApproval)); + + Ok(()) +} + +#[tokio::test] +async fn browser_feature_requirements_are_valid() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(codex_config::ConfigRequirementsToml { + feature_requirements: Some(codex_config::FeatureRequirementsToml { + entries: BTreeMap::from([ + ("in_app_browser".to_string(), false), + ("browser_use".to_string(), false), + ]), + }), + ..Default::default() + })) + })) + .build() + .await?; + + assert!(!config.features.enabled(Feature::InAppBrowser)); + assert!(!config.features.enabled(Feature::BrowserUse)); + + Ok(()) +} + #[tokio::test] async fn explicit_feature_config_is_normalized_by_requirements() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -6338,8 +7118,8 @@ shell_tool = true .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(crate::config_loader::ConfigRequirementsToml { - feature_requirements: Some(crate::config_loader::FeatureRequirementsToml { + Ok(Some(codex_config::ConfigRequirementsToml { + feature_requirements: Some(codex_config::FeatureRequirementsToml { entries: BTreeMap::from([ ("personality".to_string(), true), ("shell_tool".to_string(), false), @@ -6473,10 +7253,7 @@ approvals_reviewer = "guardian_subagent" .build() .await?; - assert_eq!( - config.approvals_reviewer, - ApprovalsReviewer::GuardianSubagent - ); + assert_eq!(config.approvals_reviewer, ApprovalsReviewer::AutoReview); Ok(()) } @@ -6488,18 +7265,15 @@ async fn requirements_disallowing_default_approvals_reviewer_falls_back_to_requi let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(crate::config_loader::ConfigRequirementsToml { - allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::GuardianSubagent]), + Ok(Some(codex_config::ConfigRequirementsToml { + allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), ..Default::default() })) })) .build() .await?; - assert_eq!( - config.approvals_reviewer, - ApprovalsReviewer::GuardianSubagent - ); + assert_eq!(config.approvals_reviewer, ApprovalsReviewer::AutoReview); Ok(()) } @@ -6517,18 +7291,15 @@ async fn root_approvals_reviewer_falls_back_when_disallowed_by_requirements() -> .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(crate::config_loader::ConfigRequirementsToml { - allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::GuardianSubagent]), + Ok(Some(codex_config::ConfigRequirementsToml { + allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), ..Default::default() })) })) .build() .await?; - assert_eq!( - config.approvals_reviewer, - ApprovalsReviewer::GuardianSubagent - ); + assert_eq!(config.approvals_reviewer, ApprovalsReviewer::AutoReview); assert!( config.startup_warnings.iter().any(|warning| { warning @@ -6557,18 +7328,15 @@ approvals_reviewer = "user" .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(crate::config_loader::ConfigRequirementsToml { - allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::GuardianSubagent]), + Ok(Some(codex_config::ConfigRequirementsToml { + allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), ..Default::default() })) })) .build() .await?; - assert_eq!( - config.approvals_reviewer, - ApprovalsReviewer::GuardianSubagent - ); + assert_eq!(config.approvals_reviewer, ApprovalsReviewer::AutoReview); Ok(()) } @@ -6586,10 +7354,10 @@ async fn approvals_reviewer_preserves_valid_user_choice_when_allowed_by_requirem .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(crate::config_loader::ConfigRequirementsToml { + Ok(Some(codex_config::ConfigRequirementsToml { allowed_approvals_reviewers: Some(vec![ ApprovalsReviewer::User, - ApprovalsReviewer::GuardianSubagent, + ApprovalsReviewer::AutoReview, ]), ..Default::default() })) @@ -6597,10 +7365,7 @@ async fn approvals_reviewer_preserves_valid_user_choice_when_allowed_by_requirem .build() .await?; - assert_eq!( - config.approvals_reviewer, - ApprovalsReviewer::GuardianSubagent - ); + assert_eq!(config.approvals_reviewer, ApprovalsReviewer::AutoReview); assert!( config .startup_warnings @@ -6628,7 +7393,7 @@ smart_approvals = true .build() .await?; - assert!(!config.features.enabled(Feature::GuardianApproval)); + assert!(config.features.enabled(Feature::GuardianApproval)); assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User); let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; @@ -6657,7 +7422,7 @@ smart_approvals = true .build() .await?; - assert!(!config.features.enabled(Feature::GuardianApproval)); + assert!(config.features.enabled(Feature::GuardianApproval)); assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User); let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; @@ -6676,6 +7441,7 @@ async fn multi_agent_v2_config_from_feature_table() -> std::io::Result<()> { codex_home.path().join(CONFIG_TOML_FILE), r#"[features.multi_agent_v2] enabled = true +max_concurrent_threads_per_session = 5 usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." hide_spawn_agent_metadata = true @@ -6689,6 +7455,8 @@ hide_spawn_agent_metadata = true .await?; assert!(config.features.enabled(Feature::MultiAgentV2)); + assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 5); + assert_eq!(config.agent_max_threads, Some(4)); assert!(!config.multi_agent_v2.usage_hint_enabled); assert_eq!( config.multi_agent_v2.usage_hint_text.as_deref(), @@ -6707,11 +7475,13 @@ async fn profile_multi_agent_v2_config_overrides_base() -> std::io::Result<()> { r#"profile = "no_hint" [features.multi_agent_v2] +max_concurrent_threads_per_session = 4 usage_hint_enabled = true usage_hint_text = "base hint" hide_spawn_agent_metadata = true [profiles.no_hint.features.multi_agent_v2] +max_concurrent_threads_per_session = 6 usage_hint_enabled = false usage_hint_text = "profile hint" hide_spawn_agent_metadata = false @@ -6724,6 +7494,7 @@ hide_spawn_agent_metadata = false .build() .await?; + assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 6); assert!(!config.multi_agent_v2.usage_hint_enabled); assert_eq!( config.multi_agent_v2.usage_hint_text.as_deref(), @@ -6734,6 +7505,80 @@ hide_spawn_agent_metadata = false Ok(()) } +#[tokio::test] +async fn multi_agent_v2_default_session_thread_cap_counts_root() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[features.multi_agent_v2] +enabled = true +"#, + )?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await?; + + assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 4); + assert_eq!(config.agent_max_threads, Some(3)); + + Ok(()) +} + +#[tokio::test] +async fn multi_agent_v2_rejects_agents_max_threads() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[features.multi_agent_v2] +enabled = true + +[agents] +max_threads = 3 +"#, + )?; + + let err = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await + .expect_err("agents.max_threads should conflict with multi_agent_v2"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "agents.max_threads cannot be set when multi_agent_v2 is enabled" + ); + + Ok(()) +} + +#[tokio::test] +async fn multi_agent_v2_session_thread_cap_one_disallows_subagents() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[features.multi_agent_v2] +enabled = true +max_concurrent_threads_per_session = 1 +"#, + )?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await?; + + assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 1); + assert_eq!(config.agent_max_threads, Some(0)); + + Ok(()) +} + #[tokio::test] async fn feature_requirements_normalize_runtime_feature_mutations() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -6741,8 +7586,8 @@ async fn feature_requirements_normalize_runtime_feature_mutations() -> std::io:: let mut config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(crate::config_loader::ConfigRequirementsToml { - feature_requirements: Some(crate::config_loader::FeatureRequirementsToml { + Ok(Some(codex_config::ConfigRequirementsToml { + feature_requirements: Some(codex_config::FeatureRequirementsToml { entries: BTreeMap::from([ ("personality".to_string(), true), ("shell_tool".to_string(), false), @@ -6771,29 +7616,63 @@ async fn feature_requirements_normalize_runtime_feature_mutations() -> std::io:: } #[tokio::test] -async fn feature_requirements_reject_collab_legacy_alias() { - let codex_home = TempDir::new().expect("tempdir"); +async fn feature_requirements_warn_on_collab_legacy_alias() -> std::io::Result<()> { + let codex_home = TempDir::new()?; - let err = ConfigBuilder::default() + let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(crate::config_loader::ConfigRequirementsToml { - feature_requirements: Some(crate::config_loader::FeatureRequirementsToml { + Ok(Some(codex_config::ConfigRequirementsToml { + feature_requirements: Some(codex_config::FeatureRequirementsToml { entries: BTreeMap::from([("collab".to_string(), true)]), }), ..Default::default() })) })) .build() - .await - .expect_err("legacy aliases should be rejected"); + .await?; - assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + assert!(config.features.enabled(Feature::Collab)); assert!( - err.to_string() - .contains("use canonical feature key `multi_agent`"), - "{err}" + config.startup_warnings.iter().any(|warning| { + warning.contains("Using legacy `features` requirement `collab`") + && warning.contains("prefer canonical feature key `multi_agent`") + }), + "{:?}", + config.startup_warnings ); + + Ok(()) +} + +#[tokio::test] +async fn feature_requirements_warn_and_ignore_unknown_feature() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(codex_config::ConfigRequirementsToml { + feature_requirements: Some(codex_config::FeatureRequirementsToml { + entries: BTreeMap::from([("made_up_feature".to_string(), true)]), + }), + ..Default::default() + })) + })) + .build() + .await?; + + assert!( + config + .startup_warnings + .iter() + .any(|warning| warning + .contains("Ignoring unknown `features` requirement `made_up_feature`")), + "{:?}", + config.startup_warnings + ); + + Ok(()) } #[tokio::test] @@ -6885,6 +7764,35 @@ experimental_realtime_start_instructions = "start instructions from config" Ok(()) } +#[tokio::test] +async fn experimental_thread_config_endpoint_loads_from_config_toml() -> std::io::Result<()> { + let cfg: ConfigToml = toml::from_str( + r#" +experimental_thread_config_endpoint = "http://127.0.0.1:8061" +"#, + ) + .expect("TOML deserialization should succeed"); + + assert_eq!( + cfg.experimental_thread_config_endpoint.as_deref(), + Some("http://127.0.0.1:8061") + ); + + let codex_home = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert_eq!( + config.experimental_thread_config_endpoint.as_deref(), + Some("http://127.0.0.1:8061") + ); + Ok(()) +} + #[tokio::test] async fn experimental_realtime_ws_base_url_loads_from_config_toml() -> std::io::Result<()> { let cfg: ConfigToml = toml::from_str( diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index e7cf3651c1..e49dc9dc08 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -37,6 +37,8 @@ pub enum ConfigEdit { SetNoticeHideFullAccessWarning(bool), /// Toggle the Windows world-writable directories warning acknowledgement flag. SetNoticeHideWorldWritableWarning(bool), + /// Toggle the opt-out marker for Codex-managed fast defaults. + SetNoticeFastDefaultOptOut(bool), /// Toggle the rate limit model nudge acknowledgement flag. SetNoticeHideRateLimitModelNudge(bool), /// Toggle the Windows onboarding acknowledgement flag. @@ -436,6 +438,11 @@ impl ConfigDocument { &[NOTICE_TABLE_KEY, "hide_world_writable_warning"], value(*acknowledged), )), + ConfigEdit::SetNoticeFastDefaultOptOut(opted_out) => Ok(self.write_value( + Scope::Global, + &[NOTICE_TABLE_KEY, "fast_default_opt_out"], + value(*opted_out), + )), ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged) => Ok(self.write_value( Scope::Global, &[NOTICE_TABLE_KEY, "hide_rate_limit_model_nudge"], @@ -978,6 +985,12 @@ impl ConfigEditsBuilder { self } + pub fn set_fast_default_opt_out(mut self, opted_out: bool) -> Self { + self.edits + .push(ConfigEdit::SetNoticeFastDefaultOptOut(opted_out)); + self + } + pub fn set_hide_rate_limit_model_nudge(mut self, acknowledged: bool) -> Self { self.edits .push(ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged)); diff --git a/codex-rs/core/src/config/managed_features.rs b/codex-rs/core/src/config/managed_features.rs index 1265d5e719..fd3032920d 100644 --- a/codex-rs/core/src/config/managed_features.rs +++ b/codex-rs/core/src/config/managed_features.rs @@ -31,13 +31,37 @@ impl ManagedFeatures { pub(crate) fn from_configured( configured_features: Features, feature_requirements: Option>, + ) -> std::io::Result { + Self::from_configured_with_optional_warnings( + configured_features, + feature_requirements, + /*startup_warnings*/ None, + ) + } + + pub(crate) fn from_configured_with_warnings( + configured_features: Features, + feature_requirements: Option>, + startup_warnings: &mut Vec, + ) -> std::io::Result { + Self::from_configured_with_optional_warnings( + configured_features, + feature_requirements, + Some(startup_warnings), + ) + } + + fn from_configured_with_optional_warnings( + configured_features: Features, + feature_requirements: Option>, + startup_warnings: Option<&mut Vec>, ) -> std::io::Result { let (pinned_features, source) = match feature_requirements { Some(Sourced { value: feature_requirements, source, }) => ( - parse_feature_requirements(feature_requirements, &source)?, + parse_feature_requirements(feature_requirements, &source, startup_warnings), Some(source), ), None => (BTreeMap::new(), None), @@ -171,31 +195,49 @@ fn feature_requirements_display(feature_requirements: &BTreeMap) fn parse_feature_requirements( feature_requirements: FeatureRequirementsToml, source: &RequirementSource, -) -> std::io::Result> { + mut startup_warnings: Option<&mut Vec>, +) -> BTreeMap { let mut pinned_features = BTreeMap::new(); for (key, enabled) in feature_requirements.entries { + if key == "auto_review" { + pinned_features.insert(Feature::GuardianApproval, enabled); + continue; + } + if let Some(feature) = canonical_feature_for_key(&key) { pinned_features.insert(feature, enabled); continue; } if let Some(feature) = feature_for_key(&key) { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, + push_feature_requirement_warning( + &mut startup_warnings, format!( - "invalid `features` requirement `{key}` from {source}: use canonical feature key `{}`", + "Using legacy `features` requirement `{key}` from {source}; prefer canonical feature key `{}`", feature.key() ), - )); + ); + pinned_features.insert(feature, enabled); + continue; } - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("invalid `features` requirement `{key}` from {source}"), - )); + push_feature_requirement_warning( + &mut startup_warnings, + format!("Ignoring unknown `features` requirement `{key}` from {source}"), + ); } - Ok(pinned_features) + pinned_features +} + +fn push_feature_requirement_warning( + startup_warnings: &mut Option<&mut Vec>, + message: String, +) { + tracing::warn!("{message}"); + if let Some(startup_warnings) = startup_warnings.as_deref_mut() { + startup_warnings.push(message); + } } fn explicit_feature_settings_in_config(cfg: &ConfigToml) -> Vec<(String, Feature, bool)> { @@ -272,7 +314,11 @@ pub(crate) fn validate_explicit_feature_settings_in_config_toml( return Ok(()); }; - let pinned_features = parse_feature_requirements(feature_requirements.clone(), source)?; + let pinned_features = parse_feature_requirements( + feature_requirements.clone(), + source, + /*startup_warnings*/ None, + ); if pinned_features.is_empty() { return Ok(()); } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 995c2008c5..f4a73dd25f 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1,34 +1,36 @@ use crate::agents_md::AgentsMdManager; use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; -use crate::config_loader::CloudRequirementsLoader; -use crate::config_loader::ConfigLayerStack; -use crate::config_loader::ConfigLayerStackOrdering; -use crate::config_loader::ConfigRequirements; -use crate::config_loader::ConfigRequirementsToml; -use crate::config_loader::ConstrainedWithSource; -use crate::config_loader::FeatureRequirementsToml; -use crate::config_loader::LoaderOverrides; -use crate::config_loader::McpServerIdentity; -use crate::config_loader::McpServerRequirement; -use crate::config_loader::ResidencyRequirement; -use crate::config_loader::Sourced; -use crate::config_loader::load_config_layers_state; -use crate::config_loader::project_trust_key; -use crate::memories::memory_root; use crate::path_utils::normalize_for_native_workdir; use crate::unified_exec::DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS; use crate::unified_exec::MIN_EMPTY_YIELD_TIME_MS; use crate::windows_sandbox::WindowsSandboxLevelExt; use crate::windows_sandbox::resolve_windows_sandbox_mode; use crate::windows_sandbox::resolve_windows_sandbox_private_desktop; +use codex_config::CloudRequirementsLoader; +use codex_config::ConfigLayerStack; +use codex_config::ConfigLayerStackOrdering; +use codex_config::ConfigRequirements; +use codex_config::ConfigRequirementsToml; +use codex_config::ConstrainedWithSource; +use codex_config::FeatureRequirementsToml; +use codex_config::LoaderOverrides; +use codex_config::McpServerIdentity; +use codex_config::McpServerRequirement; +use codex_config::ResidencyRequirement; +use codex_config::SandboxModeRequirement; +use codex_config::Sourced; use codex_config::ThreadConfigLoader; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; use codex_config::config_toml::RealtimeAudioConfig; use codex_config::config_toml::RealtimeConfig; +use codex_config::config_toml::ThreadStoreToml; use codex_config::config_toml::validate_model_providers; +use codex_config::loader::load_config_layers_state; +use codex_config::loader::project_trust_key; use codex_config::profile_toml::ConfigProfile; +use codex_config::sandbox_mode_requirement_for_permission_profile; use codex_config::types::ApprovalsReviewer; use codex_config::types::AuthCredentialsStoreMode; use codex_config::types::DEFAULT_OTEL_ENVIRONMENT; @@ -43,7 +45,6 @@ use codex_config::types::OAuthCredentialsStoreMode; use codex_config::types::OtelConfig; use codex_config::types::OtelConfigToml; use codex_config::types::OtelExporterKind; -use codex_config::types::ShellEnvironmentPolicy; use codex_config::types::ToolSuggestConfig; use codex_config::types::ToolSuggestDiscoverable; use codex_config::types::TuiNotificationSettings; @@ -61,6 +62,7 @@ use codex_features::MultiAgentV2ConfigToml; use codex_git_utils::resolve_root_git_project_for_trust; use codex_login::AuthManagerConfig; use codex_mcp::McpConfig; +use codex_memories_write::memory_root; use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR; @@ -73,11 +75,14 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::ServiceTier; +use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::Verbosity; use codex_protocol::config_types::WebSearchConfig; use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::PermissionProfile; +use codex_protocol::models::SandboxEnforcement; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::permissions::FileSystemSandboxPolicy; @@ -112,19 +117,41 @@ pub use codex_config::Constrained; pub use codex_config::ConstraintError; pub use codex_config::ConstraintResult; pub use codex_network_proxy::NetworkProxyAuditMetadata; +use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile; pub use codex_sandboxing::system_bwrap_warning; pub use managed_features::ManagedFeatures; pub use network_proxy_spec::NetworkProxySpec; pub use network_proxy_spec::StartedNetworkProxy; pub(crate) use permissions::resolve_permission_profile; -pub use codex_git_utils::GhostSnapshotConfig; +const DEFAULT_IGNORE_LARGE_UNTRACKED_DIRS: i64 = 200; +const DEFAULT_IGNORE_LARGE_UNTRACKED_FILES: i64 = 10 * 1024 * 1024; + +/// Compatibility-only config retained so legacy `ghost_snapshot` settings +/// continue to load even though snapshots are no longer produced. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GhostSnapshotConfig { + pub ignore_large_untracked_files: Option, + pub ignore_large_untracked_dirs: Option, + pub disable_warnings: bool, +} + +impl Default for GhostSnapshotConfig { + fn default() -> Self { + Self { + ignore_large_untracked_files: Some(DEFAULT_IGNORE_LARGE_UNTRACKED_FILES), + ignore_large_untracked_dirs: Some(DEFAULT_IGNORE_LARGE_UNTRACKED_DIRS), + disable_warnings: false, + } + } +} /// Maximum number of bytes of the documentation that will be embedded. Larger /// files are *silently truncated* to this size so we do not take up too much of /// the context window. pub(crate) const AGENTS_MD_MAX_BYTES: usize = 32 * 1024; // 32 KiB pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option = Some(6); +pub(crate) const DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION: usize = 4; pub(crate) const DEFAULT_AGENT_MAX_DEPTH: i32 = 1; pub(crate) const DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS: Option = None; const LOCAL_DEV_BUILD_VERSION: &str = "0.0.0"; @@ -188,14 +215,9 @@ pub(crate) async fn test_config() -> Config { pub struct Permissions { /// Approval policy for executing commands. pub approval_policy: Constrained, - /// Effective sandbox policy used for shell/unified exec. - pub sandbox_policy: Constrained, - /// Effective filesystem sandbox policy, including entries that cannot yet - /// be fully represented by the legacy [`SandboxPolicy`] projection. - pub file_system_sandbox_policy: FileSystemSandboxPolicy, - /// Effective network sandbox policy split out from the legacy - /// [`SandboxPolicy`] projection. - pub network_sandbox_policy: NetworkSandboxPolicy, + /// Canonical effective runtime permissions after config requirements and + /// runtime readable-root additions have been applied. + pub permission_profile: Constrained, /// Effective network configuration applied to all spawned processes. pub network: Option, /// Whether the model may request a login shell for shell-based tools. @@ -216,6 +238,99 @@ pub struct Permissions { pub windows_sandbox_private_desktop: bool, } +impl Permissions { + /// Effective runtime permissions after config requirements and runtime + /// readable-root additions have been applied. + pub fn permission_profile(&self) -> PermissionProfile { + self.permission_profile.get().clone() + } + + /// Effective filesystem sandbox policy derived from the canonical profile. + pub fn file_system_sandbox_policy(&self) -> FileSystemSandboxPolicy { + self.permission_profile.get().file_system_sandbox_policy() + } + + /// Effective network sandbox policy derived from the canonical profile. + pub fn network_sandbox_policy(&self) -> NetworkSandboxPolicy { + self.permission_profile.get().network_sandbox_policy() + } + + /// Legacy compatibility projection derived from the canonical profile. + pub fn legacy_sandbox_policy(&self, cwd: &Path) -> SandboxPolicy { + let permission_profile = self.permission_profile.get(); + let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); + compatibility_sandbox_policy_for_permission_profile( + permission_profile, + &file_system_sandbox_policy, + permission_profile.network_sandbox_policy(), + cwd, + ) + } + + /// Check whether a legacy sandbox policy can be applied to this permission + /// set after projecting it into the canonical permission profile. + pub fn can_set_legacy_sandbox_policy( + &self, + sandbox_policy: &SandboxPolicy, + cwd: &Path, + ) -> ConstraintResult<()> { + let file_system_sandbox_policy = + FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(sandbox_policy, cwd); + let network_sandbox_policy = NetworkSandboxPolicy::from(sandbox_policy); + let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( + SandboxEnforcement::from_legacy_sandbox_policy(sandbox_policy), + &file_system_sandbox_policy, + network_sandbox_policy, + ); + self.permission_profile.can_set(&permission_profile) + } + + /// Replace permissions from a legacy sandbox policy and keep every + /// permission projection in sync. + pub fn set_legacy_sandbox_policy( + &mut self, + sandbox_policy: SandboxPolicy, + cwd: &Path, + ) -> ConstraintResult<()> { + self.can_set_legacy_sandbox_policy(&sandbox_policy, cwd)?; + let file_system_sandbox_policy = + FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&sandbox_policy, cwd); + let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy); + let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( + SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy), + &file_system_sandbox_policy, + network_sandbox_policy, + ); + + self.permission_profile.set(permission_profile)?; + Ok(()) + } + + /// Replace permissions from the canonical profile. + pub fn set_permission_profile( + &mut self, + permission_profile: PermissionProfile, + ) -> ConstraintResult<()> { + self.permission_profile.can_set(&permission_profile)?; + + self.permission_profile.set(permission_profile)?; + Ok(()) + } +} + +/// Configured thread persistence backend. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum ThreadStoreConfig { + /// Persist threads locally using rollout JSONL files and sqlite metadata. + #[default] + Local, + /// Persist threads through the remote thread-store service. + Remote { endpoint: String }, + /// Test-only in-memory thread store. + #[cfg(debug_assertions)] + InMemory { id: String }, +} + /// Application configuration loaded from disk and merged with overrides. #[derive(Debug, Clone, PartialEq)] pub struct Config { @@ -281,7 +396,7 @@ pub struct Config { /// Developer instructions override injected as a separate message. pub developer_instructions: Option, - /// Guardian-specific tenant policy config override from requirements.toml. + /// Guardian-specific policy config override from requirements.toml or config.toml. /// This is inserted into the fixed guardian prompt template under the /// `# Policy Configuration` section rather than replacing the whole /// guardian developer prompt. @@ -360,12 +475,17 @@ pub struct Config { /// Ordered list of terminal title item identifiers for the TUI. /// - /// When unset, the TUI defaults to: `project` and `spinner`. + /// When unset, the TUI defaults to: `activity` and `project`. + /// The `activity` item spins while working and shows an action-required + /// message when blocked on the user. pub tui_terminal_title: Option>, /// Syntax highlighting theme override (kebab-case name). pub tui_theme: Option, + /// Terminal resize-reflow tuning knobs. + pub terminal_resize_reflow: TerminalResizeReflowConfig, + /// The absolute directory that should be treated as the current working /// directory for the session. All relative paths inside the business-logic /// layer are resolved against this path. @@ -418,6 +538,9 @@ pub struct Config { /// Maximum runtime in seconds for agent job workers before they are failed. pub agent_job_max_runtime_seconds: Option, + /// Whether to record a model-visible message when an agent turn is interrupted. + pub agent_interrupt_message_enabled: bool, + /// Maximum nesting depth allowed for spawned agent threads. pub agent_max_depth: i32, @@ -464,12 +587,6 @@ pub struct Config { /// code via [`ConfigOverrides`]. pub main_execve_wrapper_exe: Option, - /// Optional absolute path to the Node runtime used by `js_repl`. - pub js_repl_node_path: Option, - - /// Ordered list of directories to search for Node modules in `js_repl`. - pub js_repl_node_module_dirs: Vec, - /// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution. pub zsh_path: Option, @@ -528,9 +645,12 @@ pub struct Config { /// active. pub experimental_realtime_start_instructions: Option, - /// Experimental / do not use. When set, app-server uses a remote thread - /// store at this endpoint instead of the local filesystem/SQLite store. - pub experimental_thread_store_endpoint: Option, + /// Experimental / do not use. When set, app-server fetches thread-scoped + /// config from a remote service at this endpoint. + pub experimental_thread_config_endpoint: Option, + + /// Experimental / do not use. Selects the thread persistence backend. + pub experimental_thread_store: ThreadStoreConfig, /// When set, restricts ChatGPT login to a specific workspace identifier. pub forced_chatgpt_workspace_id: Option, @@ -555,7 +675,8 @@ pub struct Config { /// Default: `300000` (5 minutes). pub background_terminal_max_timeout: u64, - /// Settings for ghost snapshots (used for undo). + /// Compatibility-only settings retained for legacy `ghost_snapshot` + /// config loading. pub ghost_snapshot: GhostSnapshotConfig, /// Settings specific to the task-path-based multi-agent tool surface. @@ -607,6 +728,7 @@ pub struct Config { #[derive(Debug, Clone, PartialEq, Eq)] pub struct MultiAgentV2Config { + pub max_concurrent_threads_per_session: usize, pub usage_hint_enabled: bool, pub usage_hint_text: Option, pub hide_spawn_agent_metadata: bool, @@ -615,6 +737,8 @@ pub struct MultiAgentV2Config { impl Default for MultiAgentV2Config { fn default() -> Self { Self { + max_concurrent_threads_per_session: + DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION, usage_hint_enabled: true, usage_hint_text: None, hide_spawn_agent_metadata: false, @@ -622,6 +746,22 @@ impl Default for MultiAgentV2Config { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TerminalResizeReflowMaxRows { + /// Use the runtime terminal detector to choose a scrollback-sized cap. + #[default] + Auto, + /// Keep all rendered transcript rows during resize reflow. + Disabled, + /// Keep at most this many rendered transcript rows during resize reflow. + Limit(usize), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct TerminalResizeReflowConfig { + pub max_rows: TerminalResizeReflowMaxRows, +} + impl AuthManagerConfig for Config { fn codex_home(&self) -> PathBuf { self.codex_home.to_path_buf() @@ -634,9 +774,13 @@ impl AuthManagerConfig for Config { fn forced_chatgpt_workspace_id(&self) -> Option { self.forced_chatgpt_workspace_id.clone() } + + fn chatgpt_base_url(&self) -> String { + self.chatgpt_base_url.clone() + } } -#[derive(Clone)] +#[derive(Clone, Default)] pub struct ConfigBuilder { codex_home: Option, cli_overrides: Option>, @@ -645,22 +789,6 @@ pub struct ConfigBuilder { cloud_requirements: CloudRequirementsLoader, thread_config_loader: Option>, fallback_cwd: Option, - host_name: Option, -} - -impl Default for ConfigBuilder { - fn default() -> Self { - Self { - codex_home: None, - cli_overrides: None, - harness_overrides: None, - loader_overrides: None, - cloud_requirements: CloudRequirementsLoader::default(), - thread_config_loader: None, - fallback_cwd: None, - host_name: codex_config::host_name(), - } - } } impl ConfigBuilder { @@ -702,11 +830,6 @@ impl ConfigBuilder { self } - pub fn host_name(mut self, host_name: Option) -> Self { - self.host_name = host_name; - self - } - pub async fn build(self) -> std::io::Result { let Self { codex_home, @@ -716,7 +839,6 @@ impl ConfigBuilder { cloud_requirements, thread_config_loader, fallback_cwd, - host_name, } = self; let codex_home = match codex_home { Some(codex_home) => AbsolutePathBuf::from_absolute_path(codex_home)?, @@ -741,7 +863,6 @@ impl ConfigBuilder { thread_config_loader .as_deref() .unwrap_or(&codex_config::NoopThreadConfigLoader), - host_name.as_deref(), ) .await?; let merged_toml = config_layer_stack.effective_config(); @@ -753,10 +874,13 @@ impl ConfigBuilder { let config_toml: ConfigToml = match merged_toml.try_into() { Ok(config_toml) => config_toml, Err(err) => { - if let Some(config_error) = - crate::config_loader::first_layer_config_error(&config_layer_stack).await + if let Some(config_error) = codex_config::first_layer_config_error::( + &config_layer_stack, + codex_config::CONFIG_TOML_FILE, + ) + .await { - return Err(crate::config_loader::io_error_from_config_error( + return Err(codex_config::io_error_from_config_error( std::io::ErrorKind::InvalidData, config_error, Some(err), @@ -782,6 +906,18 @@ impl ConfigBuilder { } impl Config { + pub fn legacy_sandbox_policy(&self) -> SandboxPolicy { + self.permissions.legacy_sandbox_policy(self.cwd.as_path()) + } + + pub fn set_legacy_sandbox_policy( + &mut self, + sandbox_policy: SandboxPolicy, + ) -> ConstraintResult<()> { + self.permissions + .set_legacy_sandbox_policy(sandbox_policy, self.cwd.as_path()) + } + pub fn to_models_manager_config(&self) -> ModelsManagerConfig { ModelsManagerConfig { model_context_window: self.model_context_window, @@ -856,8 +992,8 @@ impl Config { format!("failed to serialize default config: {e}"), ) })?; - let cli_layer = crate::config_loader::build_cli_overrides_layer(&cli_overrides); - crate::config_loader::merge_toml_values(&mut merged, &cli_layer); + let cli_layer = codex_config::build_cli_overrides_layer(&cli_overrides); + codex_config::merge_toml_values(&mut merged, &cli_layer); let codex_home = AbsolutePathBuf::from_absolute_path_checked(codex_home)?; let config_toml = deserialize_config_toml_with_base(merged, &codex_home)?; Self::load_config_with_layer_stack( @@ -921,7 +1057,6 @@ pub async fn load_config_as_toml_with_cli_and_loader_overrides( loader_overrides, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; @@ -1103,7 +1238,6 @@ pub async fn load_global_mcp_servers( LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; let merged_toml = config_layer_stack.effective_config(); @@ -1272,6 +1406,21 @@ fn resolve_tool_suggest_config(config_toml: &ConfigToml) -> ToolSuggestConfig { ToolSuggestConfig { discoverables } } +fn thread_store_config( + thread_store: Option, + legacy_remote_endpoint: Option, +) -> ThreadStoreConfig { + match thread_store { + Some(ThreadStoreToml::Local {}) => ThreadStoreConfig::Local, + Some(ThreadStoreToml::Remote { endpoint }) => ThreadStoreConfig::Remote { endpoint }, + #[cfg(debug_assertions)] + Some(ThreadStoreToml::InMemory { id }) => ThreadStoreConfig::InMemory { id }, + None => legacy_remote_endpoint.map_or(ThreadStoreConfig::Local, |endpoint| { + ThreadStoreConfig::Remote { endpoint } + }), + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PermissionConfigSyntax { Legacy, @@ -1324,7 +1473,7 @@ fn resolve_permission_config_syntax( fn apply_managed_filesystem_constraints( file_system_sandbox_policy: &mut FileSystemSandboxPolicy, - filesystem_constraints: &crate::config_loader::FilesystemConstraints, + filesystem_constraints: &codex_config::FilesystemConstraints, ) { for deny_read in &filesystem_constraints.deny_read { let deny_entry = if deny_read.contains_glob() { @@ -1362,14 +1511,13 @@ pub struct ConfigOverrides { pub approval_policy: Option, pub approvals_reviewer: Option, pub sandbox_mode: Option, + pub permission_profile: Option, pub model_provider: Option, pub service_tier: Option>, pub config_profile: Option, pub codex_self_exe: Option, pub codex_linux_sandbox_exe: Option, pub main_execve_wrapper_exe: Option, - pub js_repl_node_path: Option, - pub js_repl_node_module_dirs: Option>, pub zsh_path: Option, pub base_instructions: Option, pub developer_instructions: Option, @@ -1458,6 +1606,10 @@ fn resolve_multi_agent_v2_config( let profile = multi_agent_v2_toml_config(config_profile.features.as_ref()); let default = MultiAgentV2Config::default(); + let max_concurrent_threads_per_session = profile + .and_then(|config| config.max_concurrent_threads_per_session) + .or_else(|| base.and_then(|config| config.max_concurrent_threads_per_session)) + .unwrap_or(default.max_concurrent_threads_per_session); let usage_hint_enabled = profile .and_then(|config| config.usage_hint_enabled) .or_else(|| base.and_then(|config| config.usage_hint_enabled)) @@ -1473,12 +1625,27 @@ fn resolve_multi_agent_v2_config( .unwrap_or(default.hide_spawn_agent_metadata); MultiAgentV2Config { + max_concurrent_threads_per_session, usage_hint_enabled, usage_hint_text, hide_spawn_agent_metadata, } } +fn resolve_terminal_resize_reflow_config(config_toml: &ConfigToml) -> TerminalResizeReflowConfig { + let Some(tui) = config_toml.tui.as_ref() else { + return TerminalResizeReflowConfig::default(); + }; + + TerminalResizeReflowConfig { + max_rows: match tui.terminal_resize_reflow_max_rows { + Some(0) => TerminalResizeReflowMaxRows::Disabled, + Some(rows) => TerminalResizeReflowMaxRows::Limit(rows), + None => TerminalResizeReflowMaxRows::Auto, + }, + } +} + fn multi_agent_v2_toml_config(features: Option<&FeaturesToml>) -> Option<&MultiAgentV2ConfigToml> { match features?.multi_agent_v2.as_ref()? { FeatureToml::Enabled(_) => None, @@ -1488,11 +1655,11 @@ fn multi_agent_v2_toml_config(features: Option<&FeaturesToml>) -> Option<&MultiA pub(crate) fn resolve_web_search_mode_for_turn( web_search_mode: &Constrained, - sandbox_policy: &SandboxPolicy, + permission_profile: &PermissionProfile, ) -> WebSearchMode { let preferred = web_search_mode.value(); - if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) + if matches!(permission_profile, PermissionProfile::Disabled) && preferred != WebSearchMode::Disabled { for mode in [ @@ -1557,14 +1724,16 @@ impl Config { let ConfigRequirements { approval_policy: mut constrained_approval_policy, approvals_reviewer: mut constrained_approvals_reviewer, - sandbox_policy: mut constrained_sandbox_policy, + permission_profile: mut constrained_permission_profile, web_search_mode: mut constrained_web_search_mode, feature_requirements, + managed_hooks: _, mcp_servers, exec_policy: _, enforce_residency, network: network_requirements, filesystem: filesystem_requirements, + guardian_policy_config_source: _, } = config_layer_stack.requirements().clone(); let user_instructions = AgentsMdManager::load_global_instructions(Some(&codex_home)) @@ -1579,14 +1748,13 @@ impl Config { approval_policy: approval_policy_override, approvals_reviewer: approvals_reviewer_override, sandbox_mode, + permission_profile, model_provider, service_tier: service_tier_override, config_profile: config_profile_key, codex_self_exe, codex_linux_sandbox_exe, main_execve_wrapper_exe, - js_repl_node_path: js_repl_node_path_override, - js_repl_node_module_dirs: js_repl_node_module_dirs_override, zsh_path: zsh_path_override, base_instructions, developer_instructions, @@ -1599,6 +1767,13 @@ impl Config { additional_writable_roots, } = overrides; + if sandbox_mode.is_some() && permission_profile.is_some() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "`sandbox_mode` and `permission_profile` overrides cannot both be set", + )); + } + let active_profile_name = config_profile_key .as_ref() .or(cfg.profile.as_ref()) @@ -1639,7 +1814,11 @@ impl Config { }, feature_overrides, ); - let features = ManagedFeatures::from_configured(configured_features, feature_requirements)?; + let features = ManagedFeatures::from_configured_with_warnings( + configured_features, + feature_requirements, + &mut startup_warnings, + )?; let windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg, &config_profile); let windows_sandbox_private_desktop = resolve_windows_sandbox_private_desktop(&cfg, &config_profile); @@ -1716,10 +1895,58 @@ impl Config { && has_permission_profiles); let ( configured_network_proxy_config, - sandbox_policy, + permission_profile, file_system_sandbox_policy, - network_sandbox_policy, - ) = if profiles_are_active { + ) = if let Some(mut permission_profile) = permission_profile { + let (mut file_system_sandbox_policy, network_sandbox_policy) = + permission_profile.to_runtime_permissions(); + let configured_network_proxy_config = + if network_sandbox_policy.is_enabled() && profiles_are_active { + let permissions = cfg.permissions.as_ref().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "default_permissions requires a `[permissions]` table", + ) + })?; + let default_permissions = cfg.default_permissions.as_deref().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "default_permissions requires a named permissions profile", + ) + })?; + let profile = resolve_permission_profile(permissions, default_permissions)?; + + // PermissionProfile carries the active network sandbox bit, not the configured + // proxy/allowlist policy. Keep that config so active profiles can round-trip + // without broadening network behavior. + network_proxy_config_from_profile_network(profile.network.as_ref()) + } else { + NetworkProxyConfig::default() + }; + let sandbox_policy = compatibility_sandbox_policy_for_permission_profile( + &permission_profile, + &file_system_sandbox_policy, + network_sandbox_policy, + resolved_cwd.as_path(), + ); + if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { + file_system_sandbox_policy = file_system_sandbox_policy + .with_additional_writable_roots( + resolved_cwd.as_path(), + &additional_writable_roots, + ); + permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( + permission_profile.enforcement(), + &file_system_sandbox_policy, + network_sandbox_policy, + ); + } + ( + configured_network_proxy_config, + permission_profile, + file_system_sandbox_policy, + ) + } else if profiles_are_active { let permissions = cfg.permissions.as_ref().ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::InvalidInput, @@ -1742,51 +1969,92 @@ impl Config { resolved_cwd.as_path(), &mut startup_warnings, )?; - let mut sandbox_policy = file_system_sandbox_policy - .to_legacy_sandbox_policy(network_sandbox_policy, resolved_cwd.as_path())?; + let mut permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + network_sandbox_policy, + ); + let sandbox_policy = compatibility_sandbox_policy_for_permission_profile( + &permission_profile, + &file_system_sandbox_policy, + network_sandbox_policy, + resolved_cwd.as_path(), + ); if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { file_system_sandbox_policy = file_system_sandbox_policy .with_additional_writable_roots( resolved_cwd.as_path(), &additional_writable_roots, ); - sandbox_policy = file_system_sandbox_policy - .to_legacy_sandbox_policy(network_sandbox_policy, resolved_cwd.as_path())?; + permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + network_sandbox_policy, + ); } ( configured_network_proxy_config, - sandbox_policy, + permission_profile, file_system_sandbox_policy, - network_sandbox_policy, ) } else { let configured_network_proxy_config = NetworkProxyConfig::default(); - let mut sandbox_policy = cfg - .derive_sandbox_policy( + // No named `[permissions]` profile is active, but permissions + // should still flow through the canonical profile representation. + // Derive the old `sandbox_mode` defaults as a profile first, then + // keep a legacy-compatible projection only for the remaining code + // paths that still speak `SandboxPolicy`. + let mut permission_profile = cfg + .derive_permission_profile( sandbox_mode, config_profile.sandbox_mode, windows_sandbox_level, Some(&active_project), - Some(&constrained_sandbox_policy), + Some(&constrained_permission_profile), ) .await; - if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy { - for path in &additional_writable_roots { - if !writable_roots.iter().any(|existing| existing == path) { - writable_roots.push(path.clone()); - } - } + // The legacy-derived profiles above are expected to be + // representable as `SandboxPolicy`. This guard keeps the old safe + // fallback behavior if future changes make this branch derive a + // profile with split-only filesystem semantics, such as root write + // with carveouts or writes that are not expressible as + // workspace-write roots. + if let Err(err) = permission_profile.to_legacy_sandbox_policy(resolved_cwd.as_path()) { + tracing::warn!( + error = %err, + "derived permission profile cannot be represented as a legacy sandbox policy; falling back to read-only" + ); + permission_profile = PermissionProfile::read_only(); + } + let (mut file_system_sandbox_policy, network_sandbox_policy) = + permission_profile.to_runtime_permissions(); + // `additional_writable_roots` is a legacy workspace-write knob. It + // only applies when the derived managed profile has workspace-style + // write access to the project roots; read-only, disabled, external, + // and future non-workspace profiles must not silently grow extra + // write access. + if matches!(permission_profile.enforcement(), SandboxEnforcement::Managed) + && file_system_sandbox_policy.can_write_path_with_cwd( + resolved_cwd.as_path(), + resolved_cwd.as_path(), + ) + && !file_system_sandbox_policy.has_full_disk_write_access() + { + // Keep legacy behavior for extra writable roots while storing + // the result as the canonical permission profile. Explicit + // extra roots are concrete paths, so their metadata carveouts + // are also concrete rather than symbolic `:project_roots` + // entries. + file_system_sandbox_policy = file_system_sandbox_policy + .with_additional_legacy_workspace_writable_roots(&additional_writable_roots); + permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( + permission_profile.enforcement(), + &file_system_sandbox_policy, + network_sandbox_policy, + ); } - let file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy( - &sandbox_policy, - resolved_cwd.as_path(), - ); - let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy); ( configured_network_proxy_config, - sandbox_policy, + permission_profile, file_system_sandbox_policy, - network_sandbox_policy, ) }; let approval_policy_was_explicit = approval_policy_override.is_some() @@ -1833,6 +2101,7 @@ impl Config { .unwrap_or(WebSearchMode::Cached); let web_search_config = resolve_web_search_config(&cfg, &config_profile); let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg, &config_profile); + let terminal_resize_reflow = resolve_terminal_resize_reflow_config(&cfg); let agent_roles = agent_roles::load_agent_roles(fs, &cfg, &config_layer_stack, &mut startup_warnings) @@ -1868,17 +2137,35 @@ impl Config { let history = cfg.history.unwrap_or_default(); - let agent_max_threads = cfg - .agents - .as_ref() - .and_then(|agents| agents.max_threads) - .or(DEFAULT_AGENT_MAX_THREADS); - if agent_max_threads == Some(0) { + if multi_agent_v2.max_concurrent_threads_per_session == 0 { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, - "agents.max_threads must be at least 1", + "features.multi_agent_v2.max_concurrent_threads_per_session must be at least 1", )); } + let agent_max_threads_from_config = cfg.agents.as_ref().and_then(|agents| agents.max_threads); + let agent_max_threads = if features.enabled(Feature::MultiAgentV2) { + if agent_max_threads_from_config.is_some() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "agents.max_threads cannot be set when multi_agent_v2 is enabled", + )); + } + Some( + multi_agent_v2 + .max_concurrent_threads_per_session + .saturating_sub(1), + ) + } else { + let agent_max_threads = agent_max_threads_from_config.or(DEFAULT_AGENT_MAX_THREADS); + if agent_max_threads == Some(0) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "agents.max_threads must be at least 1", + )); + } + agent_max_threads + }; let agent_max_depth = cfg .agents .as_ref() @@ -1909,6 +2196,11 @@ impl Config { "agents.job_max_runtime_seconds must fit within a 64-bit signed integer", )); } + let agent_interrupt_message_enabled = cfg + .agents + .as_ref() + .and_then(|agents| agents.interrupt_message) + .unwrap_or(true); let background_terminal_max_timeout = cfg .background_terminal_max_timeout .unwrap_or(DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS) @@ -1955,14 +2247,24 @@ impl Config { let forced_login_method = cfg.forced_login_method; let model = model.or(config_profile.model).or(cfg.model); - let service_tier = service_tier_override - .unwrap_or_else(|| config_profile.service_tier.or(cfg.service_tier)); + let mut notices = cfg.notice.unwrap_or_default(); + let service_tier = match service_tier_override { + Some(Some(service_tier)) => Some(service_tier), + Some(None) => { + // Preserve explicit standard/clear intent after the nested override + // collapses into `Config.service_tier = None`. + notices.fast_default_opt_out = Some(true); + None + } + None => config_profile.service_tier.or(cfg.service_tier), + }; let service_tier = match service_tier { Some(ServiceTier::Fast) if features.enabled(Feature::FastMode) => { Some(ServiceTier::Fast) } + Some(ServiceTier::Fast) => None, Some(ServiceTier::Flex) => Some(ServiceTier::Flex), - _ => None, + None => None, }; let compact_prompt = compact_prompt.or(cfg.compact_prompt).and_then(|value| { @@ -2009,7 +2311,14 @@ impl Config { .or(cfg.include_environment_context) .unwrap_or(true); let guardian_policy_config = - guardian_policy_config_from_requirements(config_layer_stack.requirements_toml()); + guardian_policy_config_from_requirements(config_layer_stack.requirements_toml()) + .or_else(|| { + cfg.auto_review + .as_ref() + .and_then(|auto_review| normalize_guardian_policy_config( + auto_review.policy.as_deref(), + )) + }); let personality = personality .or(config_profile.personality) .or(cfg.personality) @@ -2030,20 +2339,6 @@ impl Config { ) .await?; let compact_prompt = compact_prompt.or(file_compact_prompt); - let js_repl_node_path = js_repl_node_path_override - .or(config_profile.js_repl_node_path.map(Into::into)) - .or(cfg.js_repl_node_path.map(Into::into)); - let js_repl_node_module_dirs = js_repl_node_module_dirs_override - .or_else(|| { - config_profile - .js_repl_node_module_dirs - .map(|dirs| dirs.into_iter().map(Into::into).collect::>()) - }) - .or_else(|| { - cfg.js_repl_node_module_dirs - .map(|dirs| dirs.into_iter().map(Into::into).collect::>()) - }) - .unwrap_or_default(); let zsh_path = zsh_path_override .or(config_profile.zsh_path.map(Into::into)) .or(cfg.zsh_path.map(Into::into)); @@ -2069,8 +2364,7 @@ impl Config { .map(AbsolutePathBuf::to_path_buf) .or_else(|| resolve_sqlite_home_env(&resolved_cwd)) .unwrap_or_else(|| codex_home.to_path_buf()); - let original_sandbox_policy = sandbox_policy.clone(); - + let original_permission_profile = permission_profile.clone(); apply_requirement_constrained_value( "approval_policy", approval_policy, @@ -2084,17 +2378,22 @@ impl Config { && !filesystem_requirements.deny_read.is_empty() { let requirement_source = filesystem_requirements_source.clone(); - constrained_sandbox_policy + constrained_permission_profile .value - .add_validator(move |policy| match policy { - SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } => Ok(()), - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { - Err(ConstraintError::InvalidValue { - field_name: "sandbox_mode", - candidate: policy.to_string(), - allowed: "[read-only, workspace-write]".to_string(), - requirement_source: requirement_source.clone(), - }) + .add_validator(move |permission_profile| { + let mode = sandbox_mode_requirement_for_permission_profile(permission_profile); + match mode { + SandboxModeRequirement::ReadOnly + | SandboxModeRequirement::WorkspaceWrite => Ok(()), + SandboxModeRequirement::DangerFullAccess + | SandboxModeRequirement::ExternalSandbox => { + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: format!("{mode:?}"), + allowed: "[read-only, workspace-write]".to_string(), + requirement_source: requirement_source.clone(), + }) + } } }) .map_err(std::io::Error::from)?; @@ -2112,9 +2411,9 @@ impl Config { &mut startup_warnings, )?; apply_requirement_constrained_value( - "sandbox_mode", - sandbox_policy, - &mut constrained_sandbox_policy, + "permission_profile", + permission_profile, + &mut constrained_permission_profile, &mut startup_warnings, )?; apply_requirement_constrained_value( @@ -2132,10 +2431,11 @@ impl Config { None => (None, None), }; let has_network_requirements = network_requirements.is_some(); + let network_permission_profile = constrained_permission_profile.get().clone(); let network = NetworkProxySpec::from_config_and_constraints( configured_network_proxy_config, network_requirements, - constrained_sandbox_policy.get(), + &network_permission_profile, ) .map_err(|err| { if let Some(source) = network_requirements_source.as_ref() { @@ -2157,17 +2457,13 @@ impl Config { zsh_path.as_ref(), main_execve_wrapper_exe.as_ref(), ); - let effective_sandbox_policy = constrained_sandbox_policy.value.get().clone(); - let mut effective_file_system_sandbox_policy = - if effective_sandbox_policy == original_sandbox_policy { - file_system_sandbox_policy - } else { - FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_deny_entries( - &effective_sandbox_policy, - resolved_cwd.as_path(), - &file_system_sandbox_policy, - ) - }; + let effective_permission_profile = constrained_permission_profile.value.get().clone(); + let (mut effective_file_system_sandbox_policy, effective_network_sandbox_policy) = + effective_permission_profile.to_runtime_permissions(); + if effective_permission_profile != original_permission_profile { + effective_file_system_sandbox_policy + .preserve_deny_read_restrictions_from(&file_system_sandbox_policy); + } if let Some(Sourced { value: filesystem_requirements, .. @@ -2180,12 +2476,15 @@ impl Config { } let effective_file_system_sandbox_policy = effective_file_system_sandbox_policy .with_additional_readable_roots(resolved_cwd.as_path(), &helper_readable_roots); - let effective_network_sandbox_policy = - if effective_sandbox_policy == original_sandbox_policy { - network_sandbox_policy - } else { - NetworkSandboxPolicy::from(&effective_sandbox_policy) - }; + let effective_permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( + effective_permission_profile.enforcement(), + &effective_file_system_sandbox_policy, + effective_network_sandbox_policy, + ); + constrained_permission_profile + .value + .set(effective_permission_profile) + .map_err(std::io::Error::from)?; let config = Self { model, service_tier, @@ -2198,9 +2497,7 @@ impl Config { startup_warnings, permissions: Permissions { approval_policy: constrained_approval_policy.value, - sandbox_policy: constrained_sandbox_policy.value, - file_system_sandbox_policy: effective_file_system_sandbox_policy, - network_sandbox_policy: effective_network_sandbox_policy, + permission_profile: constrained_permission_profile.value, network, allow_login_shell, shell_environment_policy, @@ -2256,6 +2553,7 @@ impl Config { agent_roles, memories: cfg.memories.unwrap_or_default().into(), agent_job_max_runtime_seconds, + agent_interrupt_message_enabled, codex_home, sqlite_home, log_dir, @@ -2266,8 +2564,6 @@ impl Config { codex_self_exe, codex_linux_sandbox_exe, main_execve_wrapper_exe, - js_repl_node_path, - js_repl_node_module_dirs, zsh_path, hide_agent_reasoning: cfg.hide_agent_reasoning.unwrap_or(false), @@ -2314,7 +2610,11 @@ impl Config { experimental_realtime_ws_backend_prompt: cfg.experimental_realtime_ws_backend_prompt, experimental_realtime_ws_startup_context: cfg.experimental_realtime_ws_startup_context, experimental_realtime_start_instructions: cfg.experimental_realtime_start_instructions, - experimental_thread_store_endpoint: cfg.experimental_thread_store_endpoint, + experimental_thread_config_endpoint: cfg.experimental_thread_config_endpoint, + experimental_thread_store: thread_store_config( + cfg.experimental_thread_store, + cfg.experimental_thread_store_endpoint, + ), forced_chatgpt_workspace_id, forced_login_method, include_apply_patch_tool: include_apply_patch_tool_flag, @@ -2331,7 +2631,7 @@ impl Config { active_profile: active_profile_name, active_project, windows_wsl_setup_acknowledged: cfg.windows_wsl_setup_acknowledged.unwrap_or(false), - notices: cfg.notice.unwrap_or_default(), + notices, check_for_update_on_startup, disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false), analytics_enabled: config_profile @@ -2365,6 +2665,7 @@ impl Config { tui_status_line: cfg.tui.as_ref().and_then(|t| t.status_line.clone()), tui_terminal_title: cfg.tui.as_ref().and_then(|t| t.terminal_title.clone()), tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()), + terminal_resize_reflow, otel: { let t: OtelConfigToml = cfg.otel.unwrap_or_default(); let log_user_prompt = t.log_user_prompt.unwrap_or(false); @@ -2449,8 +2750,8 @@ impl Config { pub fn managed_network_requirements_enabled(&self) -> bool { !matches!( - self.permissions.sandbox_policy.get(), - SandboxPolicy::DangerFullAccess + self.permissions.permission_profile.get(), + PermissionProfile::Disabled ) && self .config_layer_stack .requirements_toml() @@ -2473,13 +2774,14 @@ pub(crate) fn uses_deprecated_instructions_file(config_layer_stack: &ConfigLayer fn guardian_policy_config_from_requirements( requirements_toml: &ConfigRequirementsToml, ) -> Option { - requirements_toml - .guardian_policy_config - .as_deref() - .and_then(|value| { - let trimmed = value.trim(); - (!trimmed.is_empty()).then(|| trimmed.to_string()) - }) + normalize_guardian_policy_config(requirements_toml.guardian_policy_config.as_deref()) +} + +fn normalize_guardian_policy_config(value: Option<&str>) -> Option { + value.and_then(|value| { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }) } fn toml_uses_deprecated_instructions_file(value: &TomlValue) -> bool { @@ -2520,3 +2822,7 @@ pub fn log_dir(cfg: &Config) -> std::io::Result { #[cfg(test)] #[path = "config_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "config_loader_tests.rs"] +mod config_loader_tests; diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index acabe24f20..631a826ac7 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -1,5 +1,5 @@ -use crate::config_loader::NetworkConstraints; use async_trait::async_trait; +use codex_config::NetworkConstraints; use codex_execpolicy::Policy; use codex_network_proxy::BlockedRequestObserver; use codex_network_proxy::ConfigReloader; @@ -16,7 +16,7 @@ use codex_network_proxy::build_config_state; use codex_network_proxy::host_and_port_from_network_addr; use codex_network_proxy::normalize_host; use codex_network_proxy::validate_policy_against_constraints; -use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::models::PermissionProfile; use std::collections::HashSet; use std::sync::Arc; @@ -89,7 +89,7 @@ impl NetworkProxySpec { pub(crate) fn from_config_and_constraints( config: NetworkProxyConfig, requirements: Option, - sandbox_policy: &SandboxPolicy, + permission_profile: &PermissionProfile, ) -> std::io::Result { let base_config = config.clone(); let hard_deny_allowlist_misses = requirements @@ -99,7 +99,7 @@ impl NetworkProxySpec { Self::apply_requirements( config, requirements, - sandbox_policy, + permission_profile, hard_deny_allowlist_misses, ) } else { @@ -122,7 +122,7 @@ impl NetworkProxySpec { pub async fn start_proxy( &self, - sandbox_policy: &SandboxPolicy, + permission_profile: &PermissionProfile, policy_decider: Option>, blocked_request_observer: Option>, enable_network_approval_flow: bool, @@ -133,10 +133,7 @@ impl NetworkProxySpec { if enable_network_approval_flow && !self.hard_deny_allowlist_misses { if let Some(policy_decider) = policy_decider { builder = builder.policy_decider_arc(policy_decider); - } else if matches!( - sandbox_policy, - SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } - ) { + } else if Self::managed_sandbox_active(permission_profile) { builder = builder .policy_decider(|_request| async { NetworkDecision::ask("not_allowed") }); } @@ -154,14 +151,14 @@ impl NetworkProxySpec { Ok(StartedNetworkProxy::new(proxy, handle)) } - pub(crate) fn recompute_for_sandbox_policy( + pub(crate) fn recompute_for_permission_profile( &self, - sandbox_policy: &SandboxPolicy, + permission_profile: &PermissionProfile, ) -> std::io::Result { Self::from_config_and_constraints( self.base_config.clone(), self.requirements.clone(), - sandbox_policy, + permission_profile, ) } @@ -216,13 +213,13 @@ impl NetworkProxySpec { fn apply_requirements( mut config: NetworkProxyConfig, requirements: &NetworkConstraints, - sandbox_policy: &SandboxPolicy, + permission_profile: &PermissionProfile, hard_deny_allowlist_misses: bool, ) -> (NetworkProxyConfig, NetworkProxyConstraints) { let mut constraints = NetworkProxyConstraints::default(); let allowlist_expansion_enabled = - Self::allowlist_expansion_enabled(sandbox_policy, hard_deny_allowlist_misses); - let denylist_expansion_enabled = Self::denylist_expansion_enabled(sandbox_policy); + Self::allowlist_expansion_enabled(permission_profile, hard_deny_allowlist_misses); + let denylist_expansion_enabled = Self::denylist_expansion_enabled(permission_profile); if let Some(enabled) = requirements.enabled { config.network.enabled = enabled; @@ -322,24 +319,22 @@ impl NetworkProxySpec { } fn allowlist_expansion_enabled( - sandbox_policy: &SandboxPolicy, + permission_profile: &PermissionProfile, hard_deny_allowlist_misses: bool, ) -> bool { - matches!( - sandbox_policy, - SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } - ) && !hard_deny_allowlist_misses + Self::managed_sandbox_active(permission_profile) && !hard_deny_allowlist_misses } fn managed_allowed_domains_only(requirements: &NetworkConstraints) -> bool { requirements.managed_allowed_domains_only.unwrap_or(false) } - fn denylist_expansion_enabled(sandbox_policy: &SandboxPolicy) -> bool { - matches!( - sandbox_policy, - SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } - ) + fn denylist_expansion_enabled(permission_profile: &PermissionProfile) -> bool { + Self::managed_sandbox_active(permission_profile) + } + + fn managed_sandbox_active(permission_profile: &PermissionProfile) -> bool { + matches!(permission_profile, PermissionProfile::Managed { .. }) } fn merge_domain_lists(mut managed: Vec, user_entries: &[String]) -> Vec { diff --git a/codex-rs/core/src/config/network_proxy_spec_tests.rs b/codex-rs/core/src/config/network_proxy_spec_tests.rs index 5ba4bd1536..14b7c1c330 100644 --- a/codex-rs/core/src/config/network_proxy_spec_tests.rs +++ b/codex-rs/core/src/config/network_proxy_spec_tests.rs @@ -1,9 +1,17 @@ use super::*; -use crate::config_loader::NetworkDomainPermissionToml; -use crate::config_loader::NetworkDomainPermissionsToml; +use codex_config::NetworkDomainPermissionToml; +use codex_config::NetworkDomainPermissionsToml; use codex_network_proxy::NetworkDomainPermission; +use codex_protocol::models::ManagedFileSystemPermissions; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; +fn permission_profile_for_sandbox_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile { + PermissionProfile::from_legacy_sandbox_policy(sandbox_policy) +} + fn domain_permissions( entries: impl IntoIterator, ) -> NetworkDomainPermissionsToml { @@ -54,7 +62,7 @@ fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &SandboxPolicy::new_read_only_policy(), + &permission_profile_for_sandbox_policy(&SandboxPolicy::new_read_only_policy()), ) .expect("config should stay within the managed allowlist"); @@ -89,7 +97,7 @@ fn requirements_allowed_domains_do_not_override_user_denies_for_same_pattern() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &SandboxPolicy::new_workspace_write_policy(), + &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), ) .expect("managed allowlist should not erase a user deny"); @@ -121,7 +129,7 @@ fn requirements_allowlist_expansion_keeps_user_entries_mutable() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &SandboxPolicy::new_workspace_write_policy(), + &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), ) .expect("managed baseline should still allow user edits"); @@ -144,6 +152,41 @@ fn requirements_allowlist_expansion_keeps_user_entries_mutable() { .expect("user allowlist entries should not become managed constraints"); } +#[test] +fn managed_unrestricted_profile_allows_domain_expansion() { + let mut config = NetworkProxyConfig::default(); + config + .network + .set_allowed_domains(vec!["api.example.com".to_string()]); + let requirements = NetworkConstraints { + domains: Some(domain_permissions([( + "*.example.com", + NetworkDomainPermissionToml::Allow, + )])), + ..Default::default() + }; + let permission_profile = PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Unrestricted, + network: NetworkSandboxPolicy::Restricted, + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &permission_profile, + ) + .expect("managed unrestricted filesystem should still use managed network constraints"); + + assert_eq!( + spec.config.network.allowed_domains(), + Some(vec![ + "*.example.com".to_string(), + "api.example.com".to_string() + ]) + ); + assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(true)); +} + #[test] fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() { let mut config = NetworkProxyConfig::default(); @@ -164,7 +207,7 @@ fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &SandboxPolicy::DangerFullAccess, + &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), ) .expect("yolo mode should pin the effective policy to the managed baseline"); @@ -198,7 +241,7 @@ fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &SandboxPolicy::new_workspace_write_policy(), + &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), ) .expect("managed baseline should still load"); @@ -227,7 +270,7 @@ fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses() let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &SandboxPolicy::new_workspace_write_policy(), + &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), ) .expect("managed-only allowlist should still load"); @@ -257,7 +300,7 @@ fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domain let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &SandboxPolicy::new_workspace_write_policy(), + &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), ) .expect("managed-only mode should treat missing managed allowlist as empty"); @@ -281,7 +324,7 @@ fn managed_allowed_domains_only_blocks_all_user_domains_in_full_access_without_m let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &SandboxPolicy::DangerFullAccess, + &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), ) .expect("managed-only mode should treat missing managed allowlist as empty"); @@ -308,7 +351,7 @@ fn deny_only_requirements_do_not_create_allow_constraints_in_full_access() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &SandboxPolicy::DangerFullAccess, + &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), ) .expect("deny-only requirements should not constrain the allowlist"); @@ -341,7 +384,7 @@ fn allow_only_requirements_do_not_create_deny_constraints_in_full_access() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &SandboxPolicy::DangerFullAccess, + &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), ) .expect("allow-only requirements should not constrain the denylist"); @@ -374,7 +417,7 @@ fn requirements_denied_domains_are_a_baseline_for_default_mode() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &SandboxPolicy::new_workspace_write_policy(), + &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), ) .expect("default mode should merge managed and user deny entries"); @@ -409,7 +452,7 @@ fn requirements_denylist_expansion_keeps_user_entries_mutable() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &SandboxPolicy::new_workspace_write_policy(), + &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), ) .expect("managed baseline should still allow user edits"); diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index 943c82c0e9..6d938e9185 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -383,6 +383,16 @@ fn validate_glob_scan_max_depth(max_depth: Option) -> io::Result bool { + contains_glob_chars_for_platform(path, cfg!(windows)) +} + +fn contains_glob_chars_for_platform(path: &str, is_windows: bool) -> bool { + let normalized_windows_path = if is_windows { + normalize_windows_device_path(path) + } else { + None + }; + let path = normalized_windows_path.as_deref().unwrap_or(path); path.chars().any(|ch| matches!(ch, '*' | '?' | '[' | ']')) } diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index e22376b214..e021db3d2d 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -30,6 +30,18 @@ fn normalize_absolute_path_for_platform_simplifies_windows_verbatim_paths() { assert_eq!(parsed, PathBuf::from(r"D:\c\x\worktrees\2508\swift-base")); } +#[test] +fn windows_verbatim_path_prefix_does_not_count_as_glob_syntax() { + assert!(!contains_glob_chars_for_platform( + r"\\?\D:\c\x\worktrees\2508\swift-base", + /*is_windows*/ true, + )); + assert!(contains_glob_chars_for_platform( + r"\\?\D:\c\x\worktrees\2508\**\*.env", + /*is_windows*/ true, + )); +} + #[tokio::test] async fn restricted_read_implicitly_allows_helper_executables() -> std::io::Result<()> { let temp_dir = TempDir::new()?; @@ -77,7 +89,7 @@ async fn restricted_read_implicitly_allows_helper_executables() -> std::io::Resu let expected_zsh = AbsolutePathBuf::try_from(zsh_path)?; let expected_allowed_arg0_dir = AbsolutePathBuf::try_from(allowed_arg0_dir)?; let expected_sibling_arg0_dir = AbsolutePathBuf::try_from(sibling_arg0_dir)?; - let policy = &config.permissions.file_system_sandbox_policy; + let policy = config.permissions.file_system_sandbox_policy(); assert!( policy.can_read_path_with_cwd(expected_zsh.as_path(), &cwd), diff --git a/codex-rs/core/src/config/schema_tests.rs b/codex-rs/core/src/config/schema_tests.rs index 31fabd64bd..dd67ead898 100644 --- a/codex-rs/core/src/config/schema_tests.rs +++ b/codex-rs/core/src/config/schema_tests.rs @@ -53,3 +53,23 @@ Run `just write-config-schema` to overwrite with your changes.\n\n{diff}" "fixture should match exactly with generated schema" ); } + +#[test] +fn config_schema_hides_unsupported_inline_mcp_bearer_token() { + let schema_json = config_schema_json().expect("serialize config schema"); + let schema_value: serde_json::Value = + serde_json::from_slice(&schema_json).expect("decode schema json"); + let properties = schema_value + .pointer("/definitions/RawMcpServerConfig/properties") + .expect("RawMcpServerConfig properties should exist") + .as_object() + .expect("RawMcpServerConfig properties should be an object"); + + assert_eq!( + ( + properties.contains_key("bearer_token"), + properties.contains_key("bearer_token_env_var"), + ), + (false, true), + ); +} diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 7641b4cb62..42cd415217 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -8,6 +8,7 @@ use std::time::Instant; use anyhow::Context; use async_channel::unbounded; +use codex_api::SharedAuthProvider; pub use codex_app_server_protocol::AppBranding; pub use codex_app_server_protocol::AppInfo; pub use codex_app_server_protocol::AppMetadata; @@ -16,8 +17,7 @@ use codex_connectors::DirectoryListResponse; use codex_exec_server::EnvironmentManager; use codex_exec_server::EnvironmentManagerArgs; use codex_exec_server::ExecServerRuntimePaths; -use codex_login::token_data::TokenData; -use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::models::PermissionProfile; use codex_tools::DiscoverableTool; use rmcp::model::ToolAnnotations; use serde::Deserialize; @@ -25,11 +25,11 @@ use serde::de::DeserializeOwned; use tracing::warn; use crate::config::Config; -use crate::config_loader::AppsRequirementsToml; use crate::mcp::McpManager; use crate::plugins::PluginsManager; use crate::plugins::list_tool_suggest_discoverable_plugins; use crate::session::INITIAL_SUBMIT_ID; +use codex_config::AppsRequirementsToml; use codex_config::types::AppToolApproval; use codex_config::types::AppsConfigToml; use codex_config::types::ToolSuggestDiscoverableType; @@ -144,7 +144,7 @@ pub async fn list_cached_accessible_connectors_from_mcp_tools( config: &Config, ) -> Option> { let auth_manager = - AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false); + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await; let auth = auth_manager.auth().await; if !config .features @@ -216,7 +216,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( environment_manager: &EnvironmentManager, ) -> anyhow::Result { let auth_manager = - AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false); + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await; let auth = auth_manager.auth().await; if !config .features @@ -253,8 +253,12 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( }); } - let auth_status_entries = - compute_auth_statuses(mcp_servers.iter(), config.mcp_oauth_credentials_store_mode).await; + let auth_status_entries = compute_auth_statuses( + mcp_servers.iter(), + config.mcp_oauth_credentials_store_mode, + auth.as_ref(), + ) + .await; let (tx_event, rx_event) = unbounded(); drop(rx_event); @@ -270,11 +274,12 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( &config.permissions.approval_policy, INITIAL_SUBMIT_ID.to_owned(), tx_event, - SandboxPolicy::new_read_only_policy(), + PermissionProfile::default(), McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()), config.codex_home.to_path_buf(), codex_apps_tools_cache_key(auth.as_ref()), ToolPluginProvenance::default(), + auth.as_ref(), ) .await; @@ -351,16 +356,9 @@ fn accessible_connectors_cache_key( config: &Config, auth: Option<&CodexAuth>, ) -> AccessibleConnectorsCacheKey { - let token_data: Option = auth.and_then(|auth| auth.get_token_data().ok()); - let account_id = token_data - .as_ref() - .and_then(|token_data| token_data.account_id.clone()); - let chatgpt_user_id = token_data - .as_ref() - .and_then(|token_data| token_data.id_token.chatgpt_user_id.clone()); - let is_workspace_account = token_data - .as_ref() - .is_some_and(|token_data| token_data.id_token.is_workspace_account()); + let account_id = auth.and_then(CodexAuth::get_account_id); + let chatgpt_user_id = auth.and_then(CodexAuth::get_chatgpt_user_id); + let is_workspace_account = auth.is_some_and(CodexAuth::is_workspace_account); AccessibleConnectorsCacheKey { chatgpt_base_url: config.chatgpt_base_url.clone(), account_id, @@ -431,31 +429,29 @@ async fn list_directory_connectors_for_tool_suggest_with_auth( return Ok(Vec::new()); } - let token_data = if let Some(auth) = auth { - auth.get_token_data().ok() + let loaded_auth; + let auth = if let Some(auth) = auth { + Some(auth) } else { let auth_manager = - AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false); - auth_manager - .auth() - .await - .and_then(|auth| auth.get_token_data().ok()) + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await; + loaded_auth = auth_manager.auth().await; + loaded_auth.as_ref() }; - let Some(token_data) = token_data else { + let Some(auth) = auth.filter(|auth| auth.uses_codex_backend()) else { return Ok(Vec::new()); }; - let account_id = match token_data.account_id.as_deref() { + let account_id = match auth.get_account_id() { Some(account_id) if !account_id.is_empty() => account_id, _ => return Ok(Vec::new()), }; - let access_token = token_data.access_token.clone(); - let account_id = account_id.to_string(); - let is_workspace_account = token_data.id_token.is_workspace_account(); + let auth_provider = codex_model_provider::auth_provider_from_auth(auth); + let is_workspace_account = auth.is_workspace_account(); let cache_key = AllConnectorsCacheKey::new( config.chatgpt_base_url.clone(), Some(account_id.clone()), - token_data.id_token.chatgpt_user_id.clone(), + auth.get_chatgpt_user_id(), is_workspace_account, ); @@ -464,14 +460,12 @@ async fn list_directory_connectors_for_tool_suggest_with_auth( is_workspace_account, /*force_refetch*/ false, |path| { - let access_token = access_token.clone(); - let account_id = account_id.clone(); + let auth_provider = auth_provider.clone(); async move { - chatgpt_get_request_with_token::( + chatgpt_get_request_with_auth_provider::( config, path, - access_token.as_str(), - account_id.as_str(), + auth_provider, ) .await } @@ -480,18 +474,16 @@ async fn list_directory_connectors_for_tool_suggest_with_auth( .await } -async fn chatgpt_get_request_with_token( +async fn chatgpt_get_request_with_auth_provider( config: &Config, path: String, - access_token: &str, - account_id: &str, + auth_provider: SharedAuthProvider, ) -> anyhow::Result { let client = create_client(); let url = format!("{}{}", config.chatgpt_base_url, path); let response = client .get(&url) - .bearer_auth(access_token) - .header("chatgpt-account-id", account_id) + .headers(auth_provider.to_auth_headers()) .header("Content-Type", "application/json") .timeout(DIRECTORY_CONNECTORS_TIMEOUT) .send() diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index 0f9e834d8d..885b573dac 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -1,12 +1,12 @@ use super::*; use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; -use crate::config_loader::AppRequirementToml; -use crate::config_loader::AppsRequirementsToml; -use crate::config_loader::CloudRequirementsLoader; -use crate::config_loader::ConfigLayerStack; -use crate::config_loader::ConfigRequirements; -use crate::config_loader::ConfigRequirementsToml; +use codex_config::AppRequirementToml; +use codex_config::AppsRequirementsToml; +use codex_config::CloudRequirementsLoader; +use codex_config::ConfigLayerStack; +use codex_config::ConfigRequirements; +use codex_config::ConfigRequirementsToml; use codex_config::types::AppConfig; use codex_config::types::AppToolConfig; use codex_config::types::AppToolsConfig; diff --git a/codex-rs/core/src/context/available_skills_instructions.rs b/codex-rs/core/src/context/available_skills_instructions.rs index aba4b20135..0a99bf62e6 100644 --- a/codex-rs/core/src/context/available_skills_instructions.rs +++ b/codex-rs/core/src/context/available_skills_instructions.rs @@ -1,4 +1,5 @@ use codex_core_skills::AvailableSkills; +use codex_core_skills::render_available_skills_body; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG; @@ -6,12 +7,14 @@ use super::ContextualUserFragment; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct AvailableSkillsInstructions { + skill_root_lines: Vec, skill_lines: Vec, } impl From for AvailableSkillsInstructions { fn from(available_skills: AvailableSkills) -> Self { Self { + skill_root_lines: available_skills.skill_root_lines, skill_lines: available_skills.skill_lines, } } @@ -23,34 +26,6 @@ impl ContextualUserFragment for AvailableSkillsInstructions { const END_MARKER: &'static str = SKILLS_INSTRUCTIONS_CLOSE_TAG; fn body(&self) -> String { - let mut lines: Vec = Vec::new(); - lines.push("## Skills".to_string()); - lines.push("A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.".to_string()); - lines.push("### Available skills".to_string()); - lines.extend(self.skill_lines.iter().cloned()); - - lines.push("### How to use skills".to_string()); - lines.push( - r###"- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths. -- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned. -- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback. -- How to use a skill (progressive disclosure): - 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow. - 2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed. - 3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything. - 4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks. - 5) If `assets/` or templates exist, reuse them instead of recreating from scratch. -- Coordination and sequencing: - - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them. - - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why. -- Context hygiene: - - Keep context small: summarize long sections instead of pasting them; only load extra files when needed. - - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked. - - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. -- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."### - .to_string(), - ); - - format!("\n{}\n", lines.join("\n")) + render_available_skills_body(&self.skill_root_lines, &self.skill_lines) } } diff --git a/codex-rs/core/src/context/fragment.rs b/codex-rs/core/src/context/fragment.rs index 34f4a7c367..1cc8f6d9b8 100644 --- a/codex-rs/core/src/context/fragment.rs +++ b/codex-rs/core/src/context/fragment.rs @@ -81,7 +81,6 @@ pub trait ContextualUserFragment { content: vec![ContentItem::InputText { text: self.render(), }], - end_turn: None, phase: None, } } diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index bbc5bcacf1..848a4f7f0c 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -20,7 +20,6 @@ mod realtime_end_instructions; mod realtime_start_instructions; mod realtime_start_with_instructions; mod skill_instructions; -mod spawn_agent_instructions; mod subagent_notification; mod turn_aborted; mod user_instructions; @@ -50,7 +49,6 @@ pub(crate) use realtime_end_instructions::RealtimeEndInstructions; pub(crate) use realtime_start_instructions::RealtimeStartInstructions; pub(crate) use realtime_start_with_instructions::RealtimeStartWithInstructions; pub(crate) use skill_instructions::SkillInstructions; -pub(crate) use spawn_agent_instructions::SpawnAgentInstructions; pub(crate) use subagent_notification::SubagentNotification; pub(crate) use turn_aborted::TurnAborted; pub(crate) use user_instructions::UserInstructions; diff --git a/codex-rs/core/src/context/permissions_instructions.rs b/codex-rs/core/src/context/permissions_instructions.rs index db420f7464..0ccd6c33a7 100644 --- a/codex-rs/core/src/context/permissions_instructions.rs +++ b/codex-rs/core/src/context/permissions_instructions.rs @@ -2,7 +2,9 @@ use super::ContextualUserFragment; use codex_execpolicy::Policy; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::SandboxMode; +use codex_protocol::models::PermissionProfile; use codex_protocol::models::format_allow_prefixes; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::NetworkAccess; @@ -57,7 +59,33 @@ pub struct PermissionsInstructions { } impl PermissionsInstructions { - /// Builds permissions instructions from the effective sandbox and approval policy. + /// Builds permissions instructions from the effective permission profile and approval policy. + pub fn from_permission_profile( + permission_profile: &PermissionProfile, + approval_policy: AskForApproval, + approvals_reviewer: ApprovalsReviewer, + exec_policy: &Policy, + cwd: &Path, + exec_permission_approvals_enabled: bool, + request_permissions_tool_enabled: bool, + ) -> Self { + let (sandbox_mode, writable_roots) = sandbox_prompt_from_profile(permission_profile, cwd); + + Self::from_permissions_with_network( + sandbox_mode, + network_access_from_policy(permission_profile.network_sandbox_policy()), + PermissionsPromptConfig { + approval_policy, + approvals_reviewer, + exec_policy, + exec_permission_approvals_enabled, + request_permissions_tool_enabled, + }, + writable_roots, + ) + } + + /// Builds permissions instructions from a legacy sandbox policy. pub fn from_policy( sandbox_policy: &SandboxPolicy, approval_policy: AskForApproval, @@ -67,33 +95,14 @@ impl PermissionsInstructions { exec_permission_approvals_enabled: bool, request_permissions_tool_enabled: bool, ) -> Self { - let network_access = if sandbox_policy.has_full_network_access() { - NetworkAccess::Enabled - } else { - NetworkAccess::Restricted - }; - - let (sandbox_mode, writable_roots) = match sandbox_policy { - SandboxPolicy::DangerFullAccess => (SandboxMode::DangerFullAccess, None), - SandboxPolicy::ReadOnly { .. } => (SandboxMode::ReadOnly, None), - SandboxPolicy::ExternalSandbox { .. } => (SandboxMode::DangerFullAccess, None), - SandboxPolicy::WorkspaceWrite { .. } => { - let roots = sandbox_policy.get_writable_roots_with_cwd(cwd); - (SandboxMode::WorkspaceWrite, Some(roots)) - } - }; - - Self::from_permissions_with_network( - sandbox_mode, - network_access, - PermissionsPromptConfig { - approval_policy, - approvals_reviewer, - exec_policy, - exec_permission_approvals_enabled, - request_permissions_tool_enabled, - }, - writable_roots, + Self::from_permission_profile( + &PermissionProfile::from_legacy_sandbox_policy(sandbox_policy), + approval_policy, + approvals_reviewer, + exec_policy, + cwd, + exec_permission_approvals_enabled, + request_permissions_tool_enabled, ) } @@ -125,6 +134,38 @@ impl PermissionsInstructions { } } +fn sandbox_prompt_from_profile( + permission_profile: &PermissionProfile, + cwd: &Path, +) -> (SandboxMode, Option>) { + match permission_profile { + PermissionProfile::Disabled | PermissionProfile::External { .. } => { + (SandboxMode::DangerFullAccess, None) + } + PermissionProfile::Managed { .. } => { + let file_system_policy = permission_profile.file_system_sandbox_policy(); + if file_system_policy.has_full_disk_write_access() { + return (SandboxMode::DangerFullAccess, None); + } + + let writable_roots = file_system_policy.get_writable_roots_with_cwd(cwd); + if writable_roots.is_empty() { + (SandboxMode::ReadOnly, None) + } else { + (SandboxMode::WorkspaceWrite, Some(writable_roots)) + } + } + } +} + +fn network_access_from_policy(network_policy: NetworkSandboxPolicy) -> NetworkAccess { + if network_policy.is_enabled() { + NetworkAccess::Enabled + } else { + NetworkAccess::Restricted + } +} + impl ContextualUserFragment for PermissionsInstructions { const ROLE: &'static str = "developer"; const START_MARKER: &'static str = ""; @@ -188,7 +229,7 @@ fn approval_text( ), }; - if approvals_reviewer == ApprovalsReviewer::GuardianSubagent + if approvals_reviewer == ApprovalsReviewer::AutoReview && approval_policy != AskForApproval::Never { format!("{text}\n\n{AUTO_REVIEW_APPROVAL_SUFFIX}") diff --git a/codex-rs/core/src/context/permissions_instructions_tests.rs b/codex-rs/core/src/context/permissions_instructions_tests.rs index 2f3e8c6400..16d5dc631a 100644 --- a/codex-rs/core/src/context/permissions_instructions_tests.rs +++ b/codex-rs/core/src/context/permissions_instructions_tests.rs @@ -1,5 +1,11 @@ use super::*; use codex_execpolicy::Decision; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -51,7 +57,6 @@ fn builds_permissions_with_network_access_override() { fn builds_permissions_from_policy() { let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -71,6 +76,36 @@ fn builds_permissions_from_policy() { assert!(text.contains("`approval_policy` is `unless-trusted`")); } +#[test] +fn builds_permissions_from_profile() { + let cwd = PathBuf::from("/tmp"); + let writable_root = + AbsolutePathBuf::from_absolute_path(cwd.join("repo")).expect("absolute path"); + let permission_profile = PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: writable_root.clone(), + }, + access: FileSystemAccessMode::Write, + }]), + NetworkSandboxPolicy::Enabled, + ); + + let instructions = PermissionsInstructions::from_permission_profile( + &permission_profile, + AskForApproval::UnlessTrusted, + ApprovalsReviewer::User, + &Policy::empty(), + &cwd, + /*exec_permission_approvals_enabled*/ false, + /*request_permissions_tool_enabled*/ false, + ); + let text = instructions.body(); + assert!(text.contains("`sandbox_mode` is `workspace-write`")); + assert!(text.contains("Network access is enabled.")); + assert!(text.contains(writable_root.to_string_lossy().as_ref())); +} + #[test] fn includes_request_rule_instructions_for_on_request() { let mut exec_policy = Policy::empty(); @@ -197,10 +232,10 @@ fn on_request_includes_tool_guidance_alongside_inline_permission_guidance_when_b } #[test] -fn guardian_subagent_approvals_append_guardian_specific_guidance() { +fn auto_review_approvals_append_auto_review_specific_guidance() { let text = approval_text( AskForApproval::OnRequest, - ApprovalsReviewer::GuardianSubagent, + ApprovalsReviewer::AutoReview, &Policy::empty(), /*exec_permission_approvals_enabled*/ false, /*request_permissions_tool_enabled*/ false, @@ -212,10 +247,10 @@ fn guardian_subagent_approvals_append_guardian_specific_guidance() { } #[test] -fn guardian_subagent_approvals_omit_guardian_specific_guidance_when_approval_is_never() { +fn auto_review_approvals_omit_auto_review_specific_guidance_when_approval_is_never() { let text = approval_text( AskForApproval::Never, - ApprovalsReviewer::GuardianSubagent, + ApprovalsReviewer::AutoReview, &Policy::empty(), /*exec_permission_approvals_enabled*/ false, /*request_permissions_tool_enabled*/ false, diff --git a/codex-rs/core/src/context/spawn_agent_instructions.rs b/codex-rs/core/src/context/spawn_agent_instructions.rs deleted file mode 100644 index f8a3f4175e..0000000000 --- a/codex-rs/core/src/context/spawn_agent_instructions.rs +++ /dev/null @@ -1,14 +0,0 @@ -use super::ContextualUserFragment; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct SpawnAgentInstructions; - -impl ContextualUserFragment for SpawnAgentInstructions { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; - - fn body(&self) -> String { - "\nYou are a newly spawned agent in a team of agents collaborating to complete a task. You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. You are responsible for returning the response to your assigned task in the final channel. When you give your response, the contents of your response in the final channel will be immediately delivered back to your parent agent. The prior conversation history was forked from your parent agent. Treat the next user message as your assigned task, and use the forked history only as background context.\n".to_string() - } -} diff --git a/codex-rs/core/src/context/turn_aborted.rs b/codex-rs/core/src/context/turn_aborted.rs index 3cc5f0c219..34c02b9cf6 100644 --- a/codex-rs/core/src/context/turn_aborted.rs +++ b/codex-rs/core/src/context/turn_aborted.rs @@ -7,6 +7,7 @@ pub(crate) struct TurnAborted { impl TurnAborted { pub(crate) const INTERRUPTED_GUIDANCE: &'static str = "The user interrupted the previous turn on purpose. Any running unified exec processes may still be running in the background. If any tools/commands were aborted, they may have partially executed."; + pub(crate) const INTERRUPTED_DEVELOPER_GUIDANCE: &'static str = "The previous turn was interrupted on purpose. Any running unified exec processes may still be running in the background. If any tools/commands were aborted, they may have partially executed."; pub(crate) fn new(guidance: impl Into) -> Self { Self { diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index c4bdc916ff..415a601980 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -103,8 +103,7 @@ impl ContextManager { { for item in items { let item_ref = item.deref(); - let is_ghost_snapshot = matches!(item_ref, ResponseItem::GhostSnapshot { .. }); - if !is_api_message(item_ref) && !is_ghost_snapshot { + if !is_api_message(item_ref) { continue; } @@ -120,8 +119,6 @@ impl ContextManager { pub(crate) fn for_prompt(mut self, input_modalities: &[InputModality]) -> Vec { self.normalize_history(input_modalities); self.items - .retain(|item| !matches!(item, ResponseItem::GhostSnapshot { .. })); - self.items } /// Returns raw items in the history. @@ -403,7 +400,6 @@ impl ContextManager { | ResponseItem::ImageGenerationCall { .. } | ResponseItem::CustomToolCall { .. } | ResponseItem::Compaction { .. } - | ResponseItem::GhostSnapshot { .. } | ResponseItem::Other => item.clone(), } } @@ -492,7 +488,6 @@ fn is_api_message(message: &ResponseItem) -> bool { | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } => true, - ResponseItem::GhostSnapshot { .. } => false, ResponseItem::Other => false, } } @@ -519,6 +514,10 @@ const RESIZED_IMAGE_BYTES_ESTIMATE: i64 = 7373; // Use a direct 32px patch count only for `detail: "original"`; // all other image inputs continue to use `RESIZED_IMAGE_BYTES_ESTIMATE`. const ORIGINAL_IMAGE_PATCH_SIZE: u32 = 32; +// See https://platform.openai.com/docs/guides/images-vision#model-sizing-behavior. +// Keep this hard-coded for now; move it into model capabilities if the patch +// budget starts changing often across model releases. +const ORIGINAL_IMAGE_MAX_PATCHES: usize = 10_000; const ORIGINAL_IMAGE_ESTIMATE_CACHE_SIZE: usize = 32; static ORIGINAL_IMAGE_ESTIMATE_CACHE: LazyLock>> = @@ -530,7 +529,6 @@ static ORIGINAL_IMAGE_ESTIMATE_CACHE: LazyLock i64 { match item { - ResponseItem::GhostSnapshot { .. } => 0, ResponseItem::Reasoning { encrypted_content: Some(content), .. @@ -621,6 +619,7 @@ fn estimate_original_image_bytes(image_url: &str) -> Option { let patches_high = height.saturating_add(patch_size.saturating_sub(1)) / patch_size; let patch_count = patches_wide.saturating_mul(patches_high); let patch_count = usize::try_from(patch_count).unwrap_or(usize::MAX); + let patch_count = patch_count.min(ORIGINAL_IMAGE_MAX_PATCHES); Some(i64::try_from(approx_bytes_for_tokens(patch_count)).unwrap_or(i64::MAX)) }) } @@ -686,7 +685,6 @@ fn is_model_generated_item(item: &ResponseItem) -> bool { ResponseItem::FunctionCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } | ResponseItem::CustomToolCallOutput { .. } - | ResponseItem::GhostSnapshot { .. } | ResponseItem::Other => false, } } diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 1df14ca8bc..74f4d29bfb 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -1,7 +1,6 @@ use super::*; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use codex_git_utils::GhostCommit; use codex_protocol::AgentPath; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::BaseInstructions; @@ -26,6 +25,7 @@ use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::truncate_text; use image::ImageBuffer; use image::ImageFormat; +use image::Luma; use image::Rgba; use pretty_assertions::assert_eq; use regex_lite::Regex; @@ -41,7 +41,6 @@ fn assistant_msg(text: &str) -> ResponseItem { content: vec![ContentItem::OutputText { text: text.to_string(), }], - end_turn: None, phase: None, } } @@ -60,7 +59,6 @@ fn inter_agent_assistant_msg(text: &str) -> ResponseItem { content: vec![ContentItem::OutputText { text: serde_json::to_string(&communication).unwrap(), }], - end_turn: None, phase: None, } } @@ -80,7 +78,6 @@ fn user_msg(text: &str) -> ResponseItem { content: vec![ContentItem::OutputText { text: text.to_string(), }], - end_turn: None, phase: None, } } @@ -92,7 +89,6 @@ fn user_input_text_msg(text: &str) -> ResponseItem { content: vec![ContentItem::InputText { text: text.to_string(), }], - end_turn: None, phase: None, } } @@ -104,7 +100,6 @@ fn developer_msg(text: &str) -> ResponseItem { content: vec![ContentItem::InputText { text: text.to_string(), }], - end_turn: None, phase: None, } } @@ -119,7 +114,6 @@ fn developer_msg_with_fragments(texts: &[&str]) -> ResponseItem { text: (*text).to_string(), }) .collect(), - end_turn: None, phase: None, } } @@ -133,6 +127,7 @@ fn reference_context_item() -> TurnContextItem { timezone: Some("America/Los_Angeles".to_string()), approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: "gpt-test".to_string(), @@ -199,7 +194,6 @@ fn filters_non_api_messages() { content: vec![ContentItem::OutputText { text: "ignored".to_string(), }], - end_turn: None, phase: None, }; let reasoning = reasoning_msg("thinking..."); @@ -230,7 +224,6 @@ fn filters_non_api_messages() { content: vec![ContentItem::OutputText { text: "hi".to_string() }], - end_turn: None, phase: None, }, ResponseItem::Message { @@ -239,7 +232,6 @@ fn filters_non_api_messages() { content: vec![ContentItem::OutputText { text: "hello".to_string() }], - end_turn: None, phase: None, } ] @@ -389,7 +381,6 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { text: "caption".to_string(), }, ], - end_turn: None, phase: None, }, ResponseItem::FunctionCall { @@ -452,7 +443,6 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { text: "caption".to_string(), }, ], - end_turn: None, phase: None, }, ResponseItem::FunctionCall { @@ -511,7 +501,6 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ], - end_turn: None, phase: None, }]); let preserved = with_images.for_prompt(&modalities); @@ -539,7 +528,6 @@ fn for_prompt_preserves_image_generation_calls_when_images_are_supported() { content: vec![ContentItem::InputText { text: "hi".to_string(), }], - end_turn: None, phase: None, }, ]); @@ -559,7 +547,6 @@ fn for_prompt_preserves_image_generation_calls_when_images_are_supported() { content: vec![ContentItem::InputText { text: "hi".to_string(), }], - end_turn: None, phase: None, } ] @@ -575,7 +562,6 @@ fn for_prompt_clears_image_generation_result_when_images_are_unsupported() { content: vec![ContentItem::InputText { text: "generate a lobster".to_string(), }], - end_turn: None, phase: None, }, ResponseItem::ImageGenerationCall { @@ -595,7 +581,6 @@ fn for_prompt_clears_image_generation_result_when_images_are_unsupported() { content: vec![ContentItem::InputText { text: "generate a lobster".to_string(), }], - end_turn: None, phase: None, }, ResponseItem::ImageGenerationCall { @@ -608,22 +593,6 @@ fn for_prompt_clears_image_generation_result_when_images_are_unsupported() { ); } -#[test] -fn get_history_for_prompt_drops_ghost_commits() { - let items = vec![ResponseItem::GhostSnapshot { - ghost_commit: GhostCommit::new( - "ghost-1".to_string(), - /*parent*/ None, - Vec::new(), - Vec::new(), - ), - }]; - let history = create_history_with_items(items); - let modalities = default_input_modalities(); - let filtered = history.for_prompt(&modalities); - assert_eq!(filtered, vec![]); -} - #[test] fn estimate_token_count_with_base_instructions_uses_provided_text() { let history = create_history_with_items(vec![assistant_msg("hello from history")]); @@ -757,7 +726,6 @@ fn replace_last_turn_images_does_not_touch_user_images() { image_url: "data:image/png;base64,AAA".to_string(), detail: Some(DEFAULT_IMAGE_DETAIL), }], - end_turn: None, phase: None, }]; let mut history = create_history_with_items(items.clone()); @@ -1689,7 +1657,6 @@ fn image_data_url_payload_does_not_dominate_message_estimate() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ], - end_turn: None, phase: None, }; let text_only_item = ResponseItem::Message { @@ -1698,7 +1665,6 @@ fn image_data_url_payload_does_not_dominate_message_estimate() { content: vec![ContentItem::InputText { text: "Here is the screenshot".to_string(), }], - end_turn: None, phase: None, }; @@ -1772,7 +1738,6 @@ fn non_base64_image_urls_are_unchanged() { image_url: "https://example.com/foo.png".to_string(), detail: Some(DEFAULT_IMAGE_DETAIL), }], - end_turn: None, phase: None, }; let function_output_item = ResponseItem::FunctionCallOutput { @@ -1804,7 +1769,6 @@ fn data_url_without_base64_marker_is_unchanged() { image_url: "data:image/svg+xml,".to_string(), detail: Some(DEFAULT_IMAGE_DETAIL), }], - end_turn: None, phase: None, }; @@ -1845,7 +1809,6 @@ fn mixed_case_data_url_markers_are_adjusted() { image_url, detail: Some(DEFAULT_IMAGE_DETAIL), }], - end_turn: None, phase: None, }; @@ -1878,7 +1841,6 @@ fn multiple_inline_images_apply_multiple_fixed_costs() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ], - end_turn: None, phase: None, }; @@ -1922,6 +1884,38 @@ fn original_detail_images_scale_with_dimensions() { assert_eq!(estimated, expected); } +#[test] +fn original_detail_images_are_capped_at_max_patch_count() { + // 3201x3201 at 32px patches yields 101 * 101 = 10,201 patches, + // which exceeds the original-detail patch budget. + let width = 3201; + let height = 3201; + let image = ImageBuffer::from_pixel(width, height, Luma([12u8])); + let mut bytes = std::io::Cursor::new(Vec::new()); + image + .write_to(&mut bytes, ImageFormat::Png) + .expect("encode png"); + let payload = BASE64_STANDARD.encode(bytes.get_ref()); + let image_url = format!("data:image/png;base64,{payload}"); + let item = ResponseItem::FunctionCallOutput { + call_id: "call-original-capped".to_string(), + output: FunctionCallOutputPayload::from_content_items(vec![ + FunctionCallOutputContentItem::InputImage { + image_url, + detail: Some(ImageDetail::Original), + }, + ]), + }; + + let raw_len = serde_json::to_string(&item).unwrap().len() as i64; + let estimated = estimate_response_item_model_visible_bytes(&item); + let capped_original_detail_image_bytes = + i64::try_from(approx_bytes_for_tokens(ORIGINAL_IMAGE_MAX_PATCHES)).unwrap(); + let expected = raw_len - payload.len() as i64 + capped_original_detail_image_bytes; + + assert_eq!(estimated, expected); +} + #[test] fn original_detail_webp_images_scale_with_dimensions() { // Same dimensions as the PNG case above, so the patch-based replacement cost is the same. @@ -1961,7 +1955,6 @@ fn text_only_items_unchanged() { content: vec![ContentItem::OutputText { text: "Hello world, this is a response.".to_string(), }], - end_turn: None, phase: None, }; diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 0b8b7a5887..1bc2cb0895 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -49,15 +49,15 @@ fn build_permissions_update_item( } let prev = previous?; - if prev.sandbox_policy == *next.sandbox_policy.get() + if prev.permission_profile() == next.permission_profile() && prev.approval_policy == next.approval_policy.value() { return None; } Some( - PermissionsInstructions::from_policy( - next.sandbox_policy.get(), + PermissionsInstructions::from_permission_profile( + &next.permission_profile, next.approval_policy.value(), next.config.approvals_reviewer, exec_policy, @@ -197,7 +197,6 @@ fn build_text_message(role: &str, text_sections: Vec) -> Option Vec { + environment_manager + .default_environment_id() + .map(|environment_id| TurnEnvironmentSelection { + environment_id: environment_id.to_string(), + cwd: cwd.clone(), + }) + .into_iter() + .collect() +} + +pub(crate) fn validate_environment_selections( + environment_manager: &EnvironmentManager, + environments: &[TurnEnvironmentSelection], +) -> CodexResult<()> { + for selected_environment in environments { + if environment_manager + .get_environment(&selected_environment.environment_id) + .is_none() + { + return Err(CodexErr::InvalidRequest(format!( + "unknown turn environment id `{}`", + selected_environment.environment_id + ))); + } + } + + Ok(()) +} + +pub(crate) fn selected_primary_environment( + environment_manager: &EnvironmentManager, + environments: &[TurnEnvironmentSelection], +) -> CodexResult>> { + environments + .first() + .map(|selected_environment| { + environment_manager + .get_environment(&selected_environment.environment_id) + .ok_or_else(|| { + CodexErr::InvalidRequest(format!( + "unknown turn environment id `{}`", + selected_environment.environment_id + )) + }) + }) + .transpose() +} + +#[cfg(test)] +mod tests { + use codex_exec_server::EnvironmentManagerArgs; + use codex_exec_server::ExecServerRuntimePaths; + use codex_exec_server::REMOTE_ENVIRONMENT_ID; + use codex_protocol::protocol::TurnEnvironmentSelection; + use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; + + use super::*; + + fn test_runtime_paths() -> ExecServerRuntimePaths { + ExecServerRuntimePaths::new( + std::env::current_exe().expect("current exe"), + /*codex_linux_sandbox_exe*/ None, + ) + .expect("runtime paths") + } + + #[tokio::test] + async fn default_thread_environment_selections_use_manager_default_id() { + let cwd = AbsolutePathBuf::current_dir().expect("cwd"); + let manager = EnvironmentManager::new(EnvironmentManagerArgs { + exec_server_url: Some("ws://127.0.0.1:8765".to_string()), + local_runtime_paths: test_runtime_paths(), + }); + + assert_eq!( + default_thread_environment_selections(&manager, &cwd), + vec![TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd, + }] + ); + } + + #[tokio::test] + async fn default_thread_environment_selections_empty_when_default_disabled() { + let cwd = AbsolutePathBuf::current_dir().expect("cwd"); + let manager = EnvironmentManager::new(EnvironmentManagerArgs { + exec_server_url: Some("none".to_string()), + local_runtime_paths: test_runtime_paths(), + }); + + assert_eq!( + default_thread_environment_selections(&manager, &cwd), + Vec::::new() + ); + } +} diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index 0cadc5fbda..85e7034405 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -34,7 +34,6 @@ fn parses_user_message_with_text_and_two_images() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ], - end_turn: None, phase: None, }; @@ -78,7 +77,6 @@ fn skips_local_image_label_text() { text: user_text.clone(), }, ], - end_turn: None, phase: None, }; @@ -108,7 +106,6 @@ fn parses_assistant_message_input_text_for_backward_compatibility() { text: "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue" .to_string(), }], - end_turn: None, phase: None, }; @@ -158,7 +155,6 @@ fn skips_unnamed_image_label_text() { text: user_text.clone(), }, ], - end_turn: None, phase: None, }; @@ -188,7 +184,6 @@ fn skips_user_instructions_and_env() { content: vec![ContentItem::InputText { text: "# AGENTS.md instructions for test_directory\n\n\ntest_text\n".to_string(), }], - end_turn: None, phase: None, }, ResponseItem::Message { @@ -197,7 +192,6 @@ fn skips_user_instructions_and_env() { content: vec![ContentItem::InputText { text: "test_text".to_string(), }], - end_turn: None, phase: None, }, ResponseItem::Message { @@ -206,7 +200,6 @@ fn skips_user_instructions_and_env() { content: vec![ContentItem::InputText { text: "# AGENTS.md instructions for test_directory\n\n\ntest_text\n".to_string(), }], - end_turn: None, phase: None, }, ResponseItem::Message { @@ -216,7 +209,6 @@ fn skips_user_instructions_and_env() { text: "\ndemo\nskills/demo/SKILL.md\nbody\n" .to_string(), }], - end_turn: None, phase: None, }, ResponseItem::Message { @@ -225,7 +217,6 @@ fn skips_user_instructions_and_env() { content: vec![ContentItem::InputText { text: "echo 42".to_string(), }], - end_turn: None, phase: None, }, ResponseItem::Message { @@ -241,7 +232,6 @@ fn skips_user_instructions_and_env() { .to_string(), }, ], - end_turn: None, phase: None, }, ]; @@ -292,7 +282,6 @@ fn parses_hook_prompt_and_hides_other_contextual_fragments() { .to_string(), }, ], - end_turn: None, phase: None, }; @@ -321,7 +310,6 @@ fn parses_agent_message() { content: vec![ContentItem::OutputText { text: "Hello from Codex".to_string(), }], - end_turn: None, phase: None, }; diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 0c841693d3..c261fd3355 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -30,6 +30,7 @@ use codex_protocol::error::Result; use codex_protocol::error::SandboxErr; use codex_protocol::exec_output::ExecToolCallOutput; use codex_protocol::exec_output::StreamOutput; +use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemSandboxKind; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; @@ -99,11 +100,13 @@ pub struct ExecParams { /// The unelevated restricted-token backend only consumes extra deny-write /// carveouts on top of the legacy `WorkspaceWrite` allow set. The elevated /// backend can also consume explicit read and write roots during setup/refresh. -/// Read-root overrides are layered on top of the baseline helper/platform roots -/// that the elevated setup path needs to launch the sandboxed command. +/// Read-root overrides are layered on top of the baseline helper roots that the +/// elevated setup path needs to launch the sandboxed command. Split policies +/// that opt into platform defaults carry that explicitly with the override. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct WindowsSandboxFilesystemOverrides { pub(crate) read_roots_override: Option>, + pub(crate) read_roots_include_platform_defaults: bool, pub(crate) write_roots_override: Option>, pub(crate) additional_deny_write_paths: Vec, } @@ -218,9 +221,7 @@ pub struct StdoutStream { #[allow(clippy::too_many_arguments)] pub async fn process_exec_tool_call( params: ExecParams, - sandbox_policy: &SandboxPolicy, - file_system_sandbox_policy: &FileSystemSandboxPolicy, - network_sandbox_policy: NetworkSandboxPolicy, + permission_profile: &PermissionProfile, sandbox_cwd: &AbsolutePathBuf, codex_linux_sandbox_exe: &Option, use_legacy_landlock: bool, @@ -228,9 +229,7 @@ pub async fn process_exec_tool_call( ) -> Result { let exec_req = build_exec_request( params, - sandbox_policy, - file_system_sandbox_policy, - network_sandbox_policy, + permission_profile, sandbox_cwd, codex_linux_sandbox_exe, use_legacy_landlock, @@ -244,9 +243,7 @@ pub async fn process_exec_tool_call( /// spawned under the requested sandbox policy. pub fn build_exec_request( params: ExecParams, - sandbox_policy: &SandboxPolicy, - file_system_sandbox_policy: &FileSystemSandboxPolicy, - network_sandbox_policy: NetworkSandboxPolicy, + permission_profile: &PermissionProfile, sandbox_cwd: &AbsolutePathBuf, codex_linux_sandbox_exe: &Option, use_legacy_landlock: bool, @@ -269,8 +266,10 @@ pub fn build_exec_request( } = params; let enforce_managed_network = network.is_some(); + let (file_system_sandbox_policy, network_sandbox_policy) = + permission_profile.to_runtime_permissions(); let sandbox_type = select_process_exec_tool_sandbox_type( - file_system_sandbox_policy, + &file_system_sandbox_policy, network_sandbox_policy, windows_sandbox_level, enforce_managed_network, @@ -302,9 +301,7 @@ pub fn build_exec_request( let mut exec_req = manager .transform(SandboxTransformRequest { command, - policy: sandbox_policy, - file_system_policy: file_system_sandbox_policy, - network_policy: network_sandbox_policy, + permissions: permission_profile, sandbox: sandbox_type, enforce_managed_network, network: network.as_ref(), @@ -324,10 +321,11 @@ pub fn build_exec_request( exec_req.windows_sandbox_level, exec_req.network.is_some(), ); + let sandbox_policy = exec_req.compatibility_sandbox_policy(); exec_req.windows_sandbox_filesystem_overrides = if use_windows_elevated_backend { resolve_windows_elevated_filesystem_overrides( exec_req.sandbox, - &exec_req.sandbox_policy, + &sandbox_policy, &exec_req.file_system_sandbox_policy, exec_req.network_sandbox_policy, sandbox_cwd, @@ -336,7 +334,7 @@ pub fn build_exec_request( } else { resolve_windows_restricted_token_filesystem_overrides( exec_req.sandbox, - &exec_req.sandbox_policy, + &sandbox_policy, &exec_req.file_system_sandbox_policy, exec_req.network_sandbox_policy, sandbox_cwd, @@ -352,6 +350,7 @@ pub(crate) async fn execute_exec_request( stdout_stream: Option, after_spawn: Option>, ) -> Result { + let sandbox_policy = exec_request.compatibility_sandbox_policy(); let ExecRequest { command, cwd, @@ -364,8 +363,7 @@ pub(crate) async fn execute_exec_request( windows_sandbox_policy_cwd: _, windows_sandbox_level, windows_sandbox_private_desktop, - sandbox_policy, - // TODO(mbolin): Use file_system_sandbox_policy instead of sandbox_policy. + permission_profile: _, file_system_sandbox_policy: _, network_sandbox_policy, windows_sandbox_filesystem_overrides, @@ -546,6 +544,8 @@ async fn exec_windows_sandbox( .unwrap_or_default(); let elevated_read_roots_override = windows_sandbox_filesystem_overrides .and_then(|overrides| overrides.read_roots_override.clone()); + let elevated_read_roots_include_platform_defaults = windows_sandbox_filesystem_overrides + .is_some_and(|overrides| overrides.read_roots_include_platform_defaults); let elevated_write_roots_override = windows_sandbox_filesystem_overrides .and_then(|overrides| overrides.write_roots_override.clone()); let elevated_deny_write_paths = windows_sandbox_filesystem_overrides @@ -571,6 +571,8 @@ async fn exec_windows_sandbox( use_private_desktop: windows_sandbox_private_desktop, proxy_enforced, read_roots_override: elevated_read_roots_override.as_deref(), + read_roots_include_platform_defaults: + elevated_read_roots_include_platform_defaults, write_roots_override: elevated_write_roots_override.as_deref(), deny_write_paths_override: &elevated_deny_write_paths, }, @@ -1064,6 +1066,7 @@ pub(crate) fn resolve_windows_restricted_token_filesystem_overrides( Ok(Some(WindowsSandboxFilesystemOverrides { read_roots_override: None, + read_roots_include_platform_defaults: false, write_roots_override: None, additional_deny_write_paths: additional_deny_write_paths .into_iter() @@ -1127,12 +1130,6 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides( .needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd); let normalize_path = |path: PathBuf| dunce::canonicalize(&path).unwrap_or(path); let legacy_writable_roots = sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); - let legacy_readable_root_set: BTreeSet = sandbox_policy - .get_readable_roots_with_cwd(sandbox_policy_cwd) - .into_iter() - .map(codex_utils_absolute_path::AbsolutePathBuf::into_path_buf) - .map(&normalize_path) - .collect(); let legacy_root_paths: BTreeSet = legacy_writable_roots .iter() .map(|root| normalize_path(root.root.to_path_buf())) @@ -1143,19 +1140,13 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides( .map(codex_utils_absolute_path::AbsolutePathBuf::into_path_buf) .map(&normalize_path) .collect(); - let split_readable_root_set: BTreeSet = split_readable_roots.iter().cloned().collect(); let split_root_paths: Vec = split_writable_roots .iter() .map(|root| normalize_path(root.root.to_path_buf())) .collect(); let split_root_path_set: BTreeSet = split_root_paths.iter().cloned().collect(); - let matches_legacy_read_access = file_system_sandbox_policy.has_full_disk_read_access() - == sandbox_policy.has_full_disk_read_access(); - let read_roots_override = if matches_legacy_read_access - && (file_system_sandbox_policy.has_full_disk_read_access() - || split_readable_root_set == legacy_readable_root_set) - { + let read_roots_override = if file_system_sandbox_policy.has_full_disk_read_access() { None } else { Some(split_readable_roots) @@ -1209,6 +1200,8 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides( } Ok(Some(WindowsSandboxFilesystemOverrides { + read_roots_include_platform_defaults: read_roots_override.is_some() + && file_system_sandbox_policy.include_platform_defaults(), read_roots_override, write_roots_override, additional_deny_write_paths, diff --git a/codex-rs/core/src/exec_env.rs b/codex-rs/core/src/exec_env.rs index ad94bc51a0..938667b12e 100644 --- a/codex-rs/core/src/exec_env.rs +++ b/codex-rs/core/src/exec_env.rs @@ -1,10 +1,11 @@ -#[cfg(test)] -use codex_config::types::EnvironmentVariablePattern; -use codex_config::types::ShellEnvironmentPolicy; use codex_protocol::ThreadId; +#[cfg(test)] +use codex_protocol::config_types::EnvironmentVariablePattern; +use codex_protocol::config_types::ShellEnvironmentPolicy; +use codex_protocol::shell_environment; use std::collections::HashMap; -pub use codex_config::shell_environment::CODEX_THREAD_ID_ENV_VAR; +pub use codex_protocol::shell_environment::CODEX_THREAD_ID_ENV_VAR; /// Construct an environment map based on the rules in the specified policy. The /// resulting map can be passed directly to `Command::envs()` after calling @@ -21,7 +22,7 @@ pub fn create_env( thread_id: Option, ) -> HashMap { let thread_id = thread_id.map(|thread_id| thread_id.to_string()); - codex_config::shell_environment::create_env(policy, thread_id.as_deref()) + shell_environment::create_env(policy, thread_id.as_deref()) } #[cfg(all(test, target_os = "windows"))] @@ -34,7 +35,7 @@ where I: IntoIterator, { let thread_id = thread_id.map(|thread_id| thread_id.to_string()); - codex_config::shell_environment::create_env_from_vars(vars, policy, thread_id.as_deref()) + shell_environment::create_env_from_vars(vars, policy, thread_id.as_deref()) } #[cfg(test)] @@ -47,7 +48,7 @@ where I: IntoIterator, { let thread_id = thread_id.map(|thread_id| thread_id.to_string()); - codex_config::shell_environment::populate_env(vars, policy, thread_id.as_deref()) + shell_environment::populate_env(vars, policy, thread_id.as_deref()) } #[cfg(test)] diff --git a/codex-rs/core/src/exec_env_tests.rs b/codex-rs/core/src/exec_env_tests.rs index 81b5c0bb30..725edd8cc5 100644 --- a/codex-rs/core/src/exec_env_tests.rs +++ b/codex-rs/core/src/exec_env_tests.rs @@ -1,5 +1,5 @@ use super::*; -use codex_config::types::ShellEnvironmentPolicyInherit; +use codex_protocol::config_types::ShellEnvironmentPolicyInherit; use maplit::hashmap; use pretty_assertions::assert_eq; diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 54ad8058d0..d092666369 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -5,9 +5,9 @@ use std::sync::Arc; use arc_swap::ArcSwap; -use crate::config_loader::ConfigLayerStack; -use crate::config_loader::ConfigLayerStackOrdering; use codex_app_server_protocol::ConfigLayerSource; +use codex_config::ConfigLayerStack; +use codex_config::ConfigLayerStackOrdering; use codex_execpolicy::AmendError; use codex_execpolicy::Decision; use codex_execpolicy::Error as ExecPolicyRuleError; @@ -20,10 +20,10 @@ use codex_execpolicy::RuleMatch; use codex_execpolicy::blocking_append_allow_prefix_rule; use codex_execpolicy::blocking_append_network_rule; use codex_protocol::approvals::ExecPolicyAmendment; +use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemSandboxKind; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::SandboxPolicy; use codex_shell_command::is_dangerous_command::command_might_be_dangerous; use codex_shell_command::is_safe_command::is_known_safe_command; use thiserror::Error; @@ -204,8 +204,9 @@ pub(crate) struct ExecPolicyManager { pub(crate) struct ExecApprovalRequest<'a> { pub(crate) command: &'a [String], pub(crate) approval_policy: AskForApproval, - pub(crate) sandbox_policy: &'a SandboxPolicy, + pub(crate) permission_profile: PermissionProfile, pub(crate) file_system_sandbox_policy: &'a FileSystemSandboxPolicy, + pub(crate) sandbox_cwd: &'a Path, pub(crate) sandbox_permissions: SandboxPermissions, pub(crate) prefix_rule: Option>, } @@ -238,8 +239,9 @@ impl ExecPolicyManager { let ExecApprovalRequest { command, approval_policy, - sandbox_policy, + permission_profile, file_system_sandbox_policy, + sandbox_cwd, sandbox_permissions, prefix_rule, } = req; @@ -252,8 +254,9 @@ impl ExecPolicyManager { let exec_policy_fallback = |cmd: &[String]| { render_decision_for_unmatched_command( approval_policy, - sandbox_policy, + &permission_profile, file_system_sandbox_policy, + sandbox_cwd, cmd, sandbox_permissions, used_complex_parsing, @@ -580,8 +583,9 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result { let sandbox_is_explicitly_disabled = matches!( - sandbox_policy, - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } + permission_profile, + PermissionProfile::Disabled | PermissionProfile::External { .. } ); if sandbox_is_explicitly_disabled { // If the sandbox is explicitly disabled, we should allow the command to run @@ -670,6 +678,22 @@ pub fn render_decision_for_unmatched_command( } } +fn profile_is_managed_read_only( + permission_profile: &PermissionProfile, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + sandbox_cwd: &Path, +) -> bool { + matches!(permission_profile, PermissionProfile::Managed { .. }) + && matches!( + file_system_sandbox_policy.kind, + FileSystemSandboxKind::Restricted + ) + && !file_system_sandbox_policy.has_full_disk_write_access() + && file_system_sandbox_policy + .get_writable_roots_with_cwd(sandbox_cwd) + .is_empty() +} + fn default_policy_path(codex_home: &Path) -> PathBuf { codex_home.join(RULES_DIR_NAME).join(DEFAULT_POLICY_FILE) } diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index fe4560a781..2db07a0fce 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -1,24 +1,26 @@ use super::*; use crate::config::Config; use crate::config::ConfigBuilder; -use crate::config_loader::ConfigLayerEntry; -use crate::config_loader::ConfigLayerStack; -use crate::config_loader::ConfigLayerStackOrdering; -use crate::config_loader::ConfigRequirements; -use crate::config_loader::ConfigRequirementsToml; -use crate::config_loader::LoaderOverrides; -use crate::config_loader::RequirementSource; -use crate::config_loader::Sourced; use codex_app_server_protocol::ConfigLayerSource; use codex_config::CONFIG_TOML_FILE; +use codex_config::ConfigLayerEntry; +use codex_config::ConfigLayerStack; +use codex_config::ConfigLayerStackOrdering; +use codex_config::ConfigRequirements; +use codex_config::ConfigRequirementsToml; +use codex_config::LoaderOverrides; +use codex_config::RequirementSource; use codex_config::RequirementsExecPolicy; +use codex_config::Sourced; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; use codex_protocol::config_types::TrustLevel; +use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::SandboxPolicy; @@ -108,6 +110,14 @@ fn read_only_file_system_sandbox_policy() -> FileSystemSandboxPolicy { }]) } +fn workspace_write_file_system_sandbox_policy() -> FileSystemSandboxPolicy { + FileSystemSandboxPolicy::workspace_write( + &[], + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ) +} + fn unrestricted_file_system_sandbox_policy() -> FileSystemSandboxPolicy { FileSystemSandboxPolicy::unrestricted() } @@ -116,6 +126,10 @@ fn external_file_system_sandbox_policy() -> FileSystemSandboxPolicy { FileSystemSandboxPolicy::external_sandbox() } +fn permission_profile_from_sandbox_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile { + PermissionProfile::from_legacy_sandbox_policy(sandbox_policy) +} + async fn test_config() -> (TempDir, Config) { let home = TempDir::new().expect("create temp dir"); let config = ConfigBuilder::without_managed_config_for_tests() @@ -954,8 +968,9 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() { request_permissions: true, mcp_elicitations: true, }), - &SandboxPolicy::new_read_only_policy(), + &permission_profile_from_sandbox_policy(&SandboxPolicy::new_read_only_policy()), &read_only_file_system_sandbox_policy(), + Path::new("/tmp"), &command, SandboxPermissions::RequireEscalated, /*used_complex_parsing*/ false, @@ -972,8 +987,9 @@ fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() { Decision::Prompt, render_decision_for_unmatched_command( AskForApproval::OnRequest, - &SandboxPolicy::DangerFullAccess, + &PermissionProfile::Disabled, &restricted_file_system_policy, + Path::new("/tmp"), &command, SandboxPermissions::RequireEscalated, /*used_complex_parsing*/ false, @@ -981,6 +997,65 @@ fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() { ); } +#[test] +fn managed_cwd_write_profile_is_not_read_only() { + let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, + }, + ]); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + NetworkSandboxPolicy::Restricted, + ); + + assert!(!profile_is_managed_read_only( + &permission_profile, + &file_system_sandbox_policy, + Path::new("/tmp/project") + )); +} + +#[test] +fn managed_unresolvable_write_profile_is_still_read_only() { + let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::unknown( + ":future_special_path", + /*subpath*/ None, + ), + }, + access: FileSystemAccessMode::Write, + }, + ]); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + NetworkSandboxPolicy::Restricted, + ); + + assert!(profile_is_managed_read_only( + &permission_profile, + &file_system_sandbox_policy, + Path::new("/tmp/project") + )); +} + #[tokio::test] async fn exec_approval_requirement_prompts_for_inline_additional_permissions_under_on_request() { assert_exec_approval_requirement_for_command( @@ -1058,8 +1133,11 @@ async fn mixed_rule_and_sandbox_prompt_prioritizes_rule_for_rejection_decision() request_permissions: true, mcp_elicitations: true, }), - sandbox_policy: &SandboxPolicy::new_read_only_policy(), + permission_profile: permission_profile_from_sandbox_policy( + &SandboxPolicy::new_read_only_policy(), + ), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }) @@ -1095,8 +1173,11 @@ async fn mixed_rule_and_sandbox_prompt_rejects_when_granular_rules_are_disabled( request_permissions: true, mcp_elicitations: true, }), - sandbox_policy: &SandboxPolicy::new_read_only_policy(), + permission_profile: permission_profile_from_sandbox_policy( + &SandboxPolicy::new_read_only_policy(), + ), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }) @@ -1119,8 +1200,11 @@ async fn exec_approval_requirement_falls_back_to_heuristics() { .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), + permission_profile: permission_profile_from_sandbox_policy( + &SandboxPolicy::new_read_only_policy(), + ), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -1144,8 +1228,11 @@ async fn empty_bash_lc_script_falls_back_to_original_command() { .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), + permission_profile: permission_profile_from_sandbox_policy( + &SandboxPolicy::new_read_only_policy(), + ), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -1173,8 +1260,11 @@ async fn whitespace_bash_lc_script_falls_back_to_original_command() { .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), + permission_profile: permission_profile_from_sandbox_policy( + &SandboxPolicy::new_read_only_policy(), + ), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -1202,8 +1292,11 @@ async fn request_rule_uses_prefix_rule() { .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), + permission_profile: permission_profile_from_sandbox_policy( + &SandboxPolicy::new_read_only_policy(), + ), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), }) @@ -1234,8 +1327,9 @@ async fn request_rule_falls_back_when_prefix_rule_does_not_approve_all_commands( .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::DangerFullAccess, + permission_profile: PermissionProfile::Disabled, file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), }) @@ -1273,8 +1367,9 @@ async fn heuristics_apply_when_other_commands_match_policy() { .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: &SandboxPolicy::DangerFullAccess, + permission_profile: PermissionProfile::Disabled, file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), + sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -1498,7 +1593,7 @@ prefix_rule(pattern=["cat"], decision="allow") command: command.clone(), approval_policy, sandbox_policy: SandboxPolicy::new_workspace_write_policy(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), + file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1759,8 +1854,11 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &sneaky_command, approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), + permission_profile: permission_profile_from_sandbox_policy( + &SandboxPolicy::new_read_only_policy(), + ), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_cwd: Path::new("/tmp"), sandbox_permissions: permissions, prefix_rule: None, }) @@ -1783,8 +1881,11 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &dangerous_command, approval_policy: AskForApproval::OnRequest, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), + permission_profile: permission_profile_from_sandbox_policy( + &SandboxPolicy::new_read_only_policy(), + ), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_cwd: Path::new("/tmp"), sandbox_permissions: permissions, prefix_rule: None, }) @@ -1803,8 +1904,11 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &dangerous_command, approval_policy: AskForApproval::Never, - sandbox_policy: &SandboxPolicy::new_read_only_policy(), + permission_profile: permission_profile_from_sandbox_policy( + &SandboxPolicy::new_read_only_policy(), + ), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), + sandbox_cwd: Path::new("/tmp"), sandbox_permissions: permissions, prefix_rule: None, }) @@ -1897,12 +2001,14 @@ async fn assert_exec_approval_requirement_for_command( None => Arc::new(Policy::empty()), }; + let permission_profile = permission_profile_from_sandbox_policy(&sandbox_policy); let requirement = ExecPolicyManager::new(policy) .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy, - sandbox_policy: &sandbox_policy, + permission_profile, file_system_sandbox_policy: &file_system_sandbox_policy, + sandbox_cwd: Path::new("/tmp"), sandbox_permissions, prefix_rule, }) diff --git a/codex-rs/core/src/exec_tests.rs b/codex-rs/core/src/exec_tests.rs index 1cfa87ff3f..a9304e75ee 100644 --- a/codex-rs/core/src/exec_tests.rs +++ b/codex-rs/core/src/exec_tests.rs @@ -1,5 +1,6 @@ use super::*; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::PermissionProfile; use codex_sandboxing::SandboxType; use core_test_support::PathBufExt; use core_test_support::PathExt; @@ -346,6 +347,7 @@ async fn process_exec_tool_call_preserves_full_buffer_capture_policy() -> Result let cwd = codex_utils_absolute_path::AbsolutePathBuf::current_dir()?; let sandbox_policy = SandboxPolicy::DangerFullAccess; + let permission_profile = PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy); let output = process_exec_tool_call( ExecParams { command, @@ -360,9 +362,7 @@ async fn process_exec_tool_call_preserves_full_buffer_capture_policy() -> Result justification: None, arg0: None, }, - &sandbox_policy, - &FileSystemSandboxPolicy::from(&sandbox_policy), - NetworkSandboxPolicy::Enabled, + &permission_profile, &cwd, &None, /*use_legacy_landlock*/ false, @@ -470,7 +470,6 @@ fn windows_restricted_token_allows_legacy_restricted_policies() { fn windows_restricted_token_allows_legacy_workspace_write_policies() { let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -492,7 +491,7 @@ fn windows_restricted_token_allows_legacy_workspace_write_policies() { } #[test] -fn windows_elevated_allows_legacy_restricted_read_policies() { +fn windows_elevated_allows_split_restricted_read_policies() { let temp_dir = tempfile::TempDir::new().expect("tempdir"); let docs = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path( temp_dir.path().join("docs"), @@ -500,13 +499,14 @@ fn windows_elevated_allows_legacy_restricted_read_policies() { .expect("absolute docs"); std::fs::create_dir_all(docs.as_path()).expect("create docs"); let policy = SandboxPolicy::ReadOnly { - access: codex_protocol::protocol::ReadOnlyAccess::Restricted { - readable_roots: vec![docs], - include_platform_defaults: false, - }, network_access: false, }; - let file_system_policy = FileSystemSandboxPolicy::from(&policy); + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { path: docs }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + ]); assert_eq!( unsupported_windows_restricted_token_sandbox_reason( @@ -528,7 +528,6 @@ fn windows_restricted_token_rejects_split_only_filesystem_policies() { std::fs::create_dir_all(&docs).expect("create docs"); let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -536,7 +535,9 @@ fn windows_restricted_token_rejects_split_only_filesystem_policies() { let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ codex_protocol::permissions::FileSystemSandboxEntry { path: codex_protocol::permissions::FileSystemPath::Special { - value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory, + value: codex_protocol::permissions::FileSystemSpecialPath::project_roots( + /*subpath*/ None, + ), }, access: codex_protocol::permissions::FileSystemAccessMode::Write, }, @@ -572,7 +573,6 @@ fn windows_restricted_token_rejects_root_write_read_only_carveouts() { std::fs::create_dir_all(&docs).expect("create docs"); let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -619,7 +619,6 @@ fn windows_restricted_token_supports_full_read_split_write_read_carveouts() { std::fs::create_dir_all(docs.as_path()).expect("create docs"); let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -633,7 +632,9 @@ fn windows_restricted_token_supports_full_read_split_write_read_carveouts() { }, codex_protocol::permissions::FileSystemSandboxEntry { path: codex_protocol::permissions::FileSystemPath::Special { - value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory, + value: codex_protocol::permissions::FileSystemSpecialPath::project_roots( + /*subpath*/ None, + ), }, access: codex_protocol::permissions::FileSystemAccessMode::Write, }, @@ -658,6 +659,7 @@ fn windows_restricted_token_supports_full_read_split_write_read_carveouts() { ), Ok(Some(WindowsSandboxFilesystemOverrides { read_roots_override: None, + read_roots_include_platform_defaults: false, write_roots_override: None, additional_deny_write_paths: expected_deny_write_paths, })) @@ -671,7 +673,6 @@ fn windows_elevated_supports_split_restricted_read_roots() { std::fs::create_dir_all(&docs).expect("create docs"); let expected_docs = dunce::canonicalize(&docs).expect("canonical docs"); let policy = SandboxPolicy::ReadOnly { - access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, }; let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ @@ -695,6 +696,7 @@ fn windows_elevated_supports_split_restricted_read_roots() { ), Ok(Some(WindowsSandboxFilesystemOverrides { read_roots_override: Some(vec![expected_docs]), + read_roots_include_platform_defaults: false, write_roots_override: None, additional_deny_write_paths: vec![], })) @@ -709,7 +711,6 @@ fn windows_elevated_supports_split_write_read_carveouts() { let expected_docs = dunce::canonicalize(&docs).expect("canonical docs"); let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -723,7 +724,9 @@ fn windows_elevated_supports_split_write_read_carveouts() { }, codex_protocol::permissions::FileSystemSandboxEntry { path: codex_protocol::permissions::FileSystemPath::Special { - value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory, + value: codex_protocol::permissions::FileSystemSpecialPath::project_roots( + /*subpath*/ None, + ), }, access: codex_protocol::permissions::FileSystemAccessMode::Write, }, @@ -747,6 +750,7 @@ fn windows_elevated_supports_split_write_read_carveouts() { ), Ok(Some(WindowsSandboxFilesystemOverrides { read_roots_override: None, + read_roots_include_platform_defaults: false, write_roots_override: None, additional_deny_write_paths: vec![ codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(expected_docs) @@ -763,7 +767,6 @@ fn windows_elevated_rejects_unreadable_split_carveouts() { std::fs::create_dir_all(&blocked).expect("create blocked"); let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -777,7 +780,9 @@ fn windows_elevated_rejects_unreadable_split_carveouts() { }, codex_protocol::permissions::FileSystemSandboxEntry { path: codex_protocol::permissions::FileSystemPath::Special { - value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory, + value: codex_protocol::permissions::FileSystemSpecialPath::project_roots( + /*subpath*/ None, + ), }, access: codex_protocol::permissions::FileSystemAccessMode::Write, }, @@ -811,7 +816,6 @@ fn windows_elevated_rejects_unreadable_globs() { let temp_dir = tempfile::TempDir::new().expect("tempdir"); let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -825,7 +829,9 @@ fn windows_elevated_rejects_unreadable_globs() { }, codex_protocol::permissions::FileSystemSandboxEntry { path: codex_protocol::permissions::FileSystemPath::Special { - value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory, + value: codex_protocol::permissions::FileSystemSpecialPath::project_roots( + /*subpath*/ None, + ), }, access: codex_protocol::permissions::FileSystemAccessMode::Write, }, @@ -861,7 +867,6 @@ fn windows_elevated_rejects_reopened_writable_descendants() { std::fs::create_dir_all(&nested).expect("create nested"); let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -875,7 +880,9 @@ fn windows_elevated_rejects_reopened_writable_descendants() { }, codex_protocol::permissions::FileSystemSandboxEntry { path: codex_protocol::permissions::FileSystemPath::Special { - value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory, + value: codex_protocol::permissions::FileSystemSpecialPath::project_roots( + /*subpath*/ None, + ), }, access: codex_protocol::permissions::FileSystemAccessMode::Write, }, @@ -1026,11 +1033,10 @@ async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> { tokio::time::sleep(Duration::from_millis(1_000)).await; cancel_tx.cancel(); }); + let permission_profile = PermissionProfile::Disabled; let result = process_exec_tool_call( params, - &SandboxPolicy::DangerFullAccess, - &FileSystemSandboxPolicy::from(&SandboxPolicy::DangerFullAccess), - NetworkSandboxPolicy::Enabled, + &permission_profile, &cwd, &None, /*use_legacy_landlock*/ false, diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs new file mode 100644 index 0000000000..f3c64f1b3a --- /dev/null +++ b/codex-rs/core/src/goals.rs @@ -0,0 +1,1639 @@ +//! Core support for persisted thread goals. +//! +//! This module bridges core sessions and the state-db goal table. It validates +//! goal mutations, converts between state and protocol shapes, emits goal-update +//! events, and owns helper hooks used by goal lifecycle behavior. + +use crate::StateDbHandle; +use crate::session::session::Session; +use crate::session::turn_context::TurnContext; +use crate::state::ActiveTurn; +use crate::state::TurnState; +use crate::tasks::RegularTask; +use anyhow::Context; +use codex_features::Feature; +use codex_protocol::config_types::ModeKind; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseInputItem; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ThreadGoal; +use codex_protocol::protocol::ThreadGoalStatus; +use codex_protocol::protocol::ThreadGoalUpdatedEvent; +use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TurnAbortReason; +use codex_protocol::protocol::validate_thread_goal_objective; +use codex_rollout::state_db::reconcile_rollout; +use codex_thread_store::LocalThreadStore; +use codex_utils_template::Template; +use futures::future::BoxFuture; +use std::sync::Arc; +use std::sync::LazyLock; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::time::Duration; +use std::time::Instant; +use tokio::sync::Mutex; +use tokio::sync::Semaphore; +use tokio::sync::SemaphorePermit; + +pub(crate) struct SetGoalRequest { + pub(crate) objective: Option, + pub(crate) status: Option, + pub(crate) token_budget: Option>, +} + +pub(crate) struct CreateGoalRequest { + pub(crate) objective: String, + pub(crate) token_budget: Option, +} + +static CONTINUATION_PROMPT_TEMPLATE: LazyLock