diff --git a/.bazelrc b/.bazelrc index 30d9ad9d34..76f81ade40 100644 --- a/.bazelrc +++ b/.bazelrc @@ -29,7 +29,6 @@ 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 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/macos-code-sign/codex.entitlements.plist b/.github/actions/macos-code-sign/codex.entitlements.plist index 218fe29530..d35e43ae58 100644 --- a/.github/actions/macos-code-sign/codex.entitlements.plist +++ b/.github/actions/macos-code-sign/codex.entitlements.plist @@ -2,15 +2,7 @@ - com.apple.application-identifier - 2DC432GLL2.com.openai.codex - com.apple.developer.team-identifier - 2DC432GLL2 com.apple.security.cs.allow-jit - keychain-access-groups - - 2DC432GLL2.com.openai.codex - 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/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:]' > /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/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 3f26f563f8..464b7d72a2 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1758,6 +1758,7 @@ dependencies = [ "codex-protocol", "crypto_box", "ed25519-dalek", + "jsonwebtoken", "pretty_assertions", "rand 0.9.3", "reqwest", @@ -1773,6 +1774,7 @@ dependencies = [ "codex-app-server-protocol", "codex-git-utils", "codex-login", + "codex-model-provider", "codex-plugin", "codex-protocol", "codex-utils-absolute-path", @@ -1840,6 +1842,7 @@ dependencies = [ "chrono", "clap", "codex-analytics", + "codex-api", "codex-app-server-protocol", "codex-arg0", "codex-backend-client", @@ -1856,6 +1859,7 @@ dependencies = [ "codex-git-utils", "codex-login", "codex-mcp", + "codex-model-provider", "codex-model-provider-info", "codex-models-manager", "codex-otel", @@ -2045,9 +2049,11 @@ 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", @@ -2071,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", @@ -2094,7 +2100,6 @@ dependencies = [ "assert_matches", "clap", "clap_complete", - "codex-api", "codex-app-server", "codex-app-server-protocol", "codex-app-server-test-client", @@ -2111,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", @@ -2203,7 +2208,6 @@ version = "0.0.0" dependencies = [ "anyhow", "async-trait", - "base64 0.22.1", "chrono", "clap", "codex-client", @@ -2212,6 +2216,7 @@ dependencies = [ "codex-core", "codex-git-utils", "codex-login", + "codex-model-provider", "codex-tui", "codex-utils-cli", "crossterm", @@ -2236,6 +2241,7 @@ dependencies = [ "anyhow", "async-trait", "chrono", + "codex-api", "codex-backend-client", "codex-git-utils", "serde", @@ -2280,15 +2286,20 @@ version = "0.0.0" dependencies = [ "anyhow", "async-trait", + "base64 0.22.1", "codex-app-server-protocol", + "codex-exec-server", "codex-execpolicy", "codex-features", + "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", @@ -2312,6 +2323,7 @@ dependencies = [ "tracing", "wildmatch", "winapi-util", + "windows-sys 0.52.0", ] [[package]] @@ -2358,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", @@ -2392,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", @@ -2443,7 +2456,6 @@ dependencies = [ "walkdir", "which 8.0.0", "whoami", - "windows-sys 0.52.0", "wiremock", "zstd 0.13.3", ] @@ -2460,6 +2472,7 @@ dependencies = [ "codex-exec-server", "codex-git-utils", "codex-login", + "codex-model-provider", "codex-otel", "codex-plugin", "codex-protocol", @@ -2491,6 +2504,7 @@ dependencies = [ "codex-config", "codex-exec-server", "codex-login", + "codex-model-provider", "codex-otel", "codex-protocol", "codex-skills", @@ -2527,6 +2541,7 @@ dependencies = [ name = "codex-device-key" version = "0.0.0" dependencies = [ + "async-trait", "base64 0.22.1", "p256", "pretty_assertions", @@ -2534,6 +2549,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", + "tokio", "url", ] @@ -2549,6 +2565,7 @@ dependencies = [ "codex-apply-patch", "codex-arg0", "codex-cloud-requirements", + "codex-config", "codex-core", "codex-feedback", "codex-git-utils", @@ -2592,7 +2609,6 @@ dependencies = [ "bytes", "codex-app-server-protocol", "codex-client", - "codex-config", "codex-protocol", "codex-sandboxing", "codex-test-binary-support", @@ -2770,7 +2786,6 @@ version = "0.0.0" dependencies = [ "cc", "clap", - "codex-config", "codex-core", "codex-protocol", "codex-sandboxing", @@ -2849,15 +2864,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", @@ -2907,19 +2923,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]] @@ -2943,32 +3002,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]] @@ -3108,6 +3156,7 @@ dependencies = [ "tracing", "ts-rs", "uuid", + "wildmatch", ] [[package]] @@ -3154,6 +3203,7 @@ dependencies = [ "anyhow", "axum", "bytes", + "codex-api", "codex-client", "codex-config", "codex-exec-server", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 648c184ec8..85c947aea2 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -46,6 +46,8 @@ members = [ "login", "codex-mcp", "mcp-server", + "memories/read", + "memories/write", "model-provider-info", "models-manager", "network-proxy", @@ -153,6 +155,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" } 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/Cargo.toml b/codex-rs/agent-identity/Cargo.toml index 7976c3354b..4610d6ec9b 100644 --- a/codex-rs/agent-identity/Cargo.toml +++ b/codex-rs/agent-identity/Cargo.toml @@ -19,6 +19,7 @@ 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"] } diff --git a/codex-rs/agent-identity/src/lib.rs b/codex-rs/agent-identity/src/lib.rs index a6d7e25dfd..bf139f7870 100644 --- a/codex-rs/agent-identity/src/lib.rs +++ b/codex-rs/agent-identity/src/lib.rs @@ -8,6 +8,7 @@ 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 _; @@ -15,10 +16,14 @@ 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; @@ -50,6 +55,18 @@ pub struct GeneratedAgentKeyMaterial { 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, @@ -98,6 +115,43 @@ pub fn authorization_header_for_agent_task( 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, @@ -117,19 +171,27 @@ pub async fn register_agent_task( 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(agent_task_registration_url( - chatgpt_base_url, - key.agent_runtime_id, - )) + .post(url) .timeout(AGENT_TASK_REGISTRATION_TIMEOUT) .json(&request) .send() .await - .context("failed to register agent task")? - .error_for_status() - .context("failed to register agent task")? + .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")?; @@ -323,6 +385,8 @@ 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::*; @@ -404,6 +468,119 @@ mod tests { ); } + #[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!( @@ -411,4 +588,12 @@ mod tests { "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 1eede34080..9f88ceeb94 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -161,11 +161,7 @@ fn sample_thread_start_response(thread_id: &str, ephemeral: bool, model: &str) - } fn sample_permission_profile() -> AppServerPermissionProfile { - CorePermissionProfile::from_legacy_sandbox_policy( - &SandboxPolicy::DangerFullAccess, - &test_path_buf("/tmp"), - ) - .into() + CorePermissionProfile::Disabled.into() } fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { @@ -319,7 +315,10 @@ 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, diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index d018d9912e..dbc15c1d6e 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -340,16 +340,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"); @@ -358,8 +351,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 73f2886f2f..98d0e6ff6b 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -23,7 +23,7 @@ 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; @@ -180,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 { diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 2ba6268da7..d920810732 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -15,12 +15,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; @@ -64,7 +64,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/reducer.rs b/codex-rs/analytics/src/reducer.rs index b40664e12a..75667faadd 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; @@ -892,7 +892,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, @@ -917,7 +918,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 @@ -962,12 +966,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" + } + } } } @@ -1058,3 +1077,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 1429fa26c2..e1614c32db 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -41,12 +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; diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index db2065f750..cf665f4a5c 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1892,61 +1892,132 @@ "type": "string" }, "PermissionProfile": { - "properties": { - "fileSystem": { - "anyOf": [ - { + "oneOf": [ + { + "description": "Codex owns sandbox construction for this profile.", + "properties": { + "fileSystem": { "$ref": "#/definitions/PermissionProfileFileSystemPermissions" }, - { - "type": "null" - } - ] - }, - "network": { - "anyOf": [ - { + "network": { "$ref": "#/definitions/PermissionProfileNetworkPermissions" }, - { - "type": "null" + "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" } - }, - "type": "object" + ] }, "PermissionProfileFileSystemPermissions": { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" + "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" + } }, - "type": "array" + "required": [ + "entries", + "type" + ], + "title": "RestrictedPermissionProfileFileSystemPermissions", + "type": "object" }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "type": "object" } - }, - "required": [ - "entries" - ], - "type": "object" + ] }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { - "type": [ - "boolean", - "null" - ] + "type": "boolean" } }, + "required": [ + "enabled" + ], "type": "object" }, "Personality": { @@ -2055,53 +2126,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", @@ -2268,12 +2292,6 @@ }, "type": "array" }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, "id": { "type": [ "string", @@ -2985,16 +3003,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -3051,16 +3059,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" @@ -3387,6 +3385,15 @@ ], "type": "object" }, + "ThreadGoalStatus": { + "enum": [ + "active", + "paused", + "budgetLimited", + "complete" + ], + "type": "string" + }, "ThreadInjectItemsParams": { "properties": { "items": { @@ -6117,4 +6124,4 @@ } ], "title": "ClientRequest" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json index 78e75d7c46..76d265c591 100644 --- a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json @@ -78,7 +78,8 @@ { "type": "null" } - ] + ], + "description": "Partial overlay used for per-command permission requests." } }, "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 59b3f5b45a..629c0b97fa 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3028,6 +3028,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" }, @@ -4727,6 +4814,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": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 84bf524739..50510adf98 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -78,7 +78,8 @@ { "type": "null" } - ] + ], + "description": "Partial overlay used for per-command permission requests." } }, "type": "object" 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 6904ef7165..47c6680ad0 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" @@ -3805,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": { @@ -5220,6 +5261,22 @@ ], "title": "ChatgptAccount", "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "amazonBedrock" + ], + "title": "AmazonBedrockAccountType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "AmazonBedrockAccount", + "type": "object" } ] }, @@ -11269,61 +11326,132 @@ ] }, "PermissionProfile": { - "properties": { - "fileSystem": { - "anyOf": [ - { + "oneOf": [ + { + "description": "Codex owns sandbox construction for this profile.", + "properties": { + "fileSystem": { "$ref": "#/definitions/v2/PermissionProfileFileSystemPermissions" }, - { - "type": "null" - } - ] - }, - "network": { - "anyOf": [ - { + "network": { "$ref": "#/definitions/v2/PermissionProfileNetworkPermissions" }, - { - "type": "null" + "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" } - }, - "type": "object" + ] }, "PermissionProfileFileSystemPermissions": { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/v2/FileSystemSandboxEntry" + "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" + } }, - "type": "array" + "required": [ + "entries", + "type" + ], + "title": "RestrictedPermissionProfileFileSystemPermissions", + "type": "object" }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "type": "object" } - }, - "required": [ - "entries" - ], - "type": "object" + ] }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { - "type": [ - "boolean", - "null" - ] + "type": "boolean" } }, + "required": [ + "enabled" + ], "type": "object" }, "Personality": { @@ -12140,53 +12268,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", @@ -12658,12 +12739,6 @@ }, "type": "array" }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, "id": { "type": [ "string", @@ -13395,16 +13470,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/v2/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -13461,16 +13526,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/v2/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" @@ -14544,7 +14599,7 @@ } ], "default": null, - "description": "Canonical active permissions view for this thread when representable. This is `null` for external sandbox policies because external enforcement cannot be round-tripped as a `PermissionProfile`." + "description": "Canonical active permissions view for this thread." }, "reasoningEffort": { "anyOf": [ @@ -14590,6 +14645,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" }, @@ -15987,7 +16133,7 @@ } ], "default": null, - "description": "Canonical active permissions view for this thread when representable. This is `null` for external sandbox policies because external enforcement cannot be round-tripped as a `PermissionProfile`." + "description": "Canonical active permissions view for this thread." }, "reasoningEffort": { "anyOf": [ @@ -16314,7 +16460,7 @@ } ], "default": null, - "description": "Canonical active permissions view for this thread when representable. This is `null` for external sandbox policies because external enforcement cannot be round-tripped as a `PermissionProfile`." + "description": "Canonical active permissions view for this thread." }, "reasoningEffort": { "anyOf": [ @@ -17606,4 +17752,4 @@ }, "title": "CodexAppServerProtocol", "type": "object" -} \ No newline at end of file +} 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 bc00828e68..455d9f16f4 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" } ] }, @@ -7984,61 +8000,132 @@ ] }, "PermissionProfile": { - "properties": { - "fileSystem": { - "anyOf": [ - { + "oneOf": [ + { + "description": "Codex owns sandbox construction for this profile.", + "properties": { + "fileSystem": { "$ref": "#/definitions/PermissionProfileFileSystemPermissions" }, - { - "type": "null" - } - ] - }, - "network": { - "anyOf": [ - { + "network": { "$ref": "#/definitions/PermissionProfileNetworkPermissions" }, - { - "type": "null" + "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" } - }, - "type": "object" + ] }, "PermissionProfileFileSystemPermissions": { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" + "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" + } }, - "type": "array" + "required": [ + "entries", + "type" + ], + "title": "RestrictedPermissionProfileFileSystemPermissions", + "type": "object" }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "type": "object" } - }, - "required": [ - "entries" - ], - "type": "object" + ] }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { - "type": [ - "boolean", - "null" - ] + "type": "boolean" } }, + "required": [ + "enabled" + ], "type": "object" }, "Personality": { @@ -8855,53 +8942,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", @@ -9373,12 +9413,6 @@ }, "type": "array" }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, "id": { "type": [ "string", @@ -10110,16 +10144,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -10176,16 +10200,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" @@ -10424,6 +10438,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": { @@ -12431,7 +12485,7 @@ } ], "default": null, - "description": "Canonical active permissions view for this thread when representable. This is `null` for external sandbox policies because external enforcement cannot be round-tripped as a `PermissionProfile`." + "description": "Canonical active permissions view for this thread." }, "reasoningEffort": { "anyOf": [ @@ -12477,6 +12531,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" }, @@ -13874,7 +14019,7 @@ } ], "default": null, - "description": "Canonical active permissions view for this thread when representable. This is `null` for external sandbox policies because external enforcement cannot be round-tripped as a `PermissionProfile`." + "description": "Canonical active permissions view for this thread." }, "reasoningEffort": { "anyOf": [ @@ -14201,7 +14346,7 @@ } ], "default": null, - "description": "Canonical active permissions view for this thread when representable. This is `null` for external sandbox policies because external enforcement cannot be round-tripped as a `PermissionProfile`." + "description": "Canonical active permissions view for this thread." }, "reasoningEffort": { "anyOf": [ @@ -15492,4 +15637,4 @@ }, "title": "CodexAppServerProtocolV2", "type": "object" -} \ No newline at end of file +} 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 4def45c049..b85a0e7911 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json @@ -246,110 +246,134 @@ "type": "string" }, "PermissionProfile": { - "properties": { - "fileSystem": { - "anyOf": [ - { + "oneOf": [ + { + "description": "Codex owns sandbox construction for this profile.", + "properties": { + "fileSystem": { "$ref": "#/definitions/PermissionProfileFileSystemPermissions" }, - { - "type": "null" - } - ] - }, - "network": { - "anyOf": [ - { + "network": { "$ref": "#/definitions/PermissionProfileNetworkPermissions" }, - { - "type": "null" + "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" } - }, - "type": "object" + ] }, "PermissionProfileFileSystemPermissions": { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "entries" - ], - "type": "object" - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": [ - "boolean", - "null" - ] - } - }, - "type": "object" - }, - "ReadOnlyAccess": { "oneOf": [ { "properties": { - "includePlatformDefaults": { - "default": true, - "type": "boolean" - }, - "readableRoots": { - "default": [], + "entries": { "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/FileSystemSandboxEntry" }, "type": "array" }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, "type": { "enum": [ "restricted" ], - "title": "RestrictedReadOnlyAccessType", + "title": "RestrictedPermissionProfileFileSystemPermissionsType", "type": "string" } }, "required": [ + "entries", "type" ], - "title": "RestrictedReadOnlyAccess", + "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": [ { @@ -370,16 +394,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -436,16 +450,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" 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/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index 956e3b2507..34e4086c59 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -345,12 +345,6 @@ }, "type": "array" }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, "id": { "type": [ "string", @@ -928,4 +922,4 @@ ], "title": "RawResponseItemCompletedNotification", "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 a603aff1e8..d120fc8b5d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -276,61 +276,132 @@ ] }, "PermissionProfile": { - "properties": { - "fileSystem": { - "anyOf": [ - { + "oneOf": [ + { + "description": "Codex owns sandbox construction for this profile.", + "properties": { + "fileSystem": { "$ref": "#/definitions/PermissionProfileFileSystemPermissions" }, - { - "type": "null" - } - ] - }, - "network": { - "anyOf": [ - { + "network": { "$ref": "#/definitions/PermissionProfileNetworkPermissions" }, - { - "type": "null" + "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" } - }, - "type": "object" + ] }, "PermissionProfileFileSystemPermissions": { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" + "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" + } }, - "type": "array" + "required": [ + "entries", + "type" + ], + "title": "RestrictedPermissionProfileFileSystemPermissions", + "type": "object" }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "type": "object" } - }, - "required": [ - "entries" - ], - "type": "object" + ] }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { - "type": [ - "boolean", - "null" - ] + "type": "boolean" } }, + "required": [ + "enabled" + ], "type": "object" }, "SandboxMode": { 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 47677da41e..a2f2490a0b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -900,110 +900,134 @@ ] }, "PermissionProfile": { - "properties": { - "fileSystem": { - "anyOf": [ - { + "oneOf": [ + { + "description": "Codex owns sandbox construction for this profile.", + "properties": { + "fileSystem": { "$ref": "#/definitions/PermissionProfileFileSystemPermissions" }, - { - "type": "null" - } - ] - }, - "network": { - "anyOf": [ - { + "network": { "$ref": "#/definitions/PermissionProfileNetworkPermissions" }, - { - "type": "null" + "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" } - }, - "type": "object" + ] }, "PermissionProfileFileSystemPermissions": { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "entries" - ], - "type": "object" - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": [ - "boolean", - "null" - ] - } - }, - "type": "object" - }, - "ReadOnlyAccess": { "oneOf": [ { "properties": { - "includePlatformDefaults": { - "default": true, - "type": "boolean" - }, - "readableRoots": { - "default": [], + "entries": { "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/FileSystemSandboxEntry" }, "type": "array" }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, "type": { "enum": [ "restricted" ], - "title": "RestrictedReadOnlyAccessType", + "title": "RestrictedPermissionProfileFileSystemPermissionsType", "type": "string" } }, "required": [ + "entries", "type" ], - "title": "RestrictedReadOnlyAccess", + "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": [ @@ -1036,16 +1060,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -1102,16 +1116,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" @@ -2506,7 +2510,7 @@ } ], "default": null, - "description": "Canonical active permissions view for this thread when representable. This is `null` for external sandbox policies because external enforcement cannot be round-tripped as a `PermissionProfile`." + "description": "Canonical active permissions view for this thread." }, "reasoningEffort": { "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/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 19ccad14c1..872a3eb324 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -542,61 +542,132 @@ ] }, "PermissionProfile": { - "properties": { - "fileSystem": { - "anyOf": [ - { + "oneOf": [ + { + "description": "Codex owns sandbox construction for this profile.", + "properties": { + "fileSystem": { "$ref": "#/definitions/PermissionProfileFileSystemPermissions" }, - { - "type": "null" - } - ] - }, - "network": { - "anyOf": [ - { + "network": { "$ref": "#/definitions/PermissionProfileNetworkPermissions" }, - { - "type": "null" + "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" } - }, - "type": "object" + ] }, "PermissionProfileFileSystemPermissions": { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" + "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" + } }, - "type": "array" + "required": [ + "entries", + "type" + ], + "title": "RestrictedPermissionProfileFileSystemPermissions", + "type": "object" }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "type": "object" } - }, - "required": [ - "entries" - ], - "type": "object" + ] }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { - "type": [ - "boolean", - "null" - ] + "type": "boolean" } }, + "required": [ + "enabled" + ], "type": "object" }, "Personality": { @@ -685,12 +756,6 @@ }, "type": "array" }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, "id": { "type": [ "string", @@ -1387,4 +1452,4 @@ ], "title": "ThreadResumeParams", "type": "object" -} \ No newline at end of file +} 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 38b0eb0d37..516627576e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -900,110 +900,134 @@ ] }, "PermissionProfile": { - "properties": { - "fileSystem": { - "anyOf": [ - { + "oneOf": [ + { + "description": "Codex owns sandbox construction for this profile.", + "properties": { + "fileSystem": { "$ref": "#/definitions/PermissionProfileFileSystemPermissions" }, - { - "type": "null" - } - ] - }, - "network": { - "anyOf": [ - { + "network": { "$ref": "#/definitions/PermissionProfileNetworkPermissions" }, - { - "type": "null" + "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" } - }, - "type": "object" + ] }, "PermissionProfileFileSystemPermissions": { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "entries" - ], - "type": "object" - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": [ - "boolean", - "null" - ] - } - }, - "type": "object" - }, - "ReadOnlyAccess": { "oneOf": [ { "properties": { - "includePlatformDefaults": { - "default": true, - "type": "boolean" - }, - "readableRoots": { - "default": [], + "entries": { "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/FileSystemSandboxEntry" }, "type": "array" }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, "type": { "enum": [ "restricted" ], - "title": "RestrictedReadOnlyAccessType", + "title": "RestrictedPermissionProfileFileSystemPermissionsType", "type": "string" } }, "required": [ + "entries", "type" ], - "title": "RestrictedReadOnlyAccess", + "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": [ @@ -1036,16 +1060,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -1102,16 +1116,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" @@ -2506,7 +2510,7 @@ } ], "default": null, - "description": "Canonical active permissions view for this thread when representable. This is `null` for external sandbox policies because external enforcement cannot be round-tripped as a `PermissionProfile`." + "description": "Canonical active permissions view for this thread." }, "reasoningEffort": { "anyOf": [ 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 0c5f217648..5a59e280ea 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -302,61 +302,132 @@ ] }, "PermissionProfile": { - "properties": { - "fileSystem": { - "anyOf": [ - { + "oneOf": [ + { + "description": "Codex owns sandbox construction for this profile.", + "properties": { + "fileSystem": { "$ref": "#/definitions/PermissionProfileFileSystemPermissions" }, - { - "type": "null" - } - ] - }, - "network": { - "anyOf": [ - { + "network": { "$ref": "#/definitions/PermissionProfileNetworkPermissions" }, - { - "type": "null" + "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" } - }, - "type": "object" + ] }, "PermissionProfileFileSystemPermissions": { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" + "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" + } }, - "type": "array" + "required": [ + "entries", + "type" + ], + "title": "RestrictedPermissionProfileFileSystemPermissions", + "type": "object" }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "type": "object" } - }, - "required": [ - "entries" - ], - "type": "object" + ] }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { - "type": [ - "boolean", - "null" - ] + "type": "boolean" } }, + "required": [ + "enabled" + ], "type": "object" }, "Personality": { @@ -388,6 +459,21 @@ "clear" ], "type": "string" + }, + "TurnEnvironmentParams": { + "properties": { + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "environmentId": { + "type": "string" + } + }, + "required": [ + "cwd", + "environmentId" + ], + "type": "object" } }, "properties": { 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 879fa5c687..f773c0be69 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -900,110 +900,134 @@ ] }, "PermissionProfile": { - "properties": { - "fileSystem": { - "anyOf": [ - { + "oneOf": [ + { + "description": "Codex owns sandbox construction for this profile.", + "properties": { + "fileSystem": { "$ref": "#/definitions/PermissionProfileFileSystemPermissions" }, - { - "type": "null" - } - ] - }, - "network": { - "anyOf": [ - { + "network": { "$ref": "#/definitions/PermissionProfileNetworkPermissions" }, - { - "type": "null" + "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" } - }, - "type": "object" + ] }, "PermissionProfileFileSystemPermissions": { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "entries" - ], - "type": "object" - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": [ - "boolean", - "null" - ] - } - }, - "type": "object" - }, - "ReadOnlyAccess": { "oneOf": [ { "properties": { - "includePlatformDefaults": { - "default": true, - "type": "boolean" - }, - "readableRoots": { - "default": [], + "entries": { "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/FileSystemSandboxEntry" }, "type": "array" }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, "type": { "enum": [ "restricted" ], - "title": "RestrictedReadOnlyAccessType", + "title": "RestrictedPermissionProfileFileSystemPermissionsType", "type": "string" } }, "required": [ + "entries", "type" ], - "title": "RestrictedReadOnlyAccess", + "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": [ @@ -1036,16 +1060,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -1102,16 +1116,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" @@ -2506,7 +2510,7 @@ } ], "default": null, - "description": "Canonical active permissions view for this thread when representable. This is `null` for external sandbox policies because external enforcement cannot be round-tripped as a `PermissionProfile`." + "description": "Canonical active permissions view for this thread." }, "reasoningEffort": { "anyOf": [ 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 062771029e..559698100f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -326,61 +326,132 @@ "type": "string" }, "PermissionProfile": { - "properties": { - "fileSystem": { - "anyOf": [ - { + "oneOf": [ + { + "description": "Codex owns sandbox construction for this profile.", + "properties": { + "fileSystem": { "$ref": "#/definitions/PermissionProfileFileSystemPermissions" }, - { - "type": "null" - } - ] - }, - "network": { - "anyOf": [ - { + "network": { "$ref": "#/definitions/PermissionProfileNetworkPermissions" }, - { - "type": "null" + "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" } - }, - "type": "object" + ] }, "PermissionProfileFileSystemPermissions": { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" + "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" + } }, - "type": "array" + "required": [ + "entries", + "type" + ], + "title": "RestrictedPermissionProfileFileSystemPermissions", + "type": "object" }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "type": "object" } - }, - "required": [ - "entries" - ], - "type": "object" + ] }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { - "type": [ - "boolean", - "null" - ] + "type": "boolean" } }, + "required": [ + "enabled" + ], "type": "object" }, "Personality": { @@ -391,53 +462,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": [ @@ -490,16 +514,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -556,16 +570,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index 04b8bdcdad..eed78b1fc0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -11,7 +11,7 @@ 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. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index 031527e3ad..41d4754bc3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -40,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"; @@ -64,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": "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 }; +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/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/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/PermissionProfile.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts index c38bde54b0..7642c27650 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts @@ -4,4 +4,4 @@ import type { PermissionProfileFileSystemPermissions } from "./PermissionProfileFileSystemPermissions"; import type { PermissionProfileNetworkPermissions } from "./PermissionProfileNetworkPermissions"; -export type PermissionProfile = { network: PermissionProfileNetworkPermissions | null, fileSystem: PermissionProfileFileSystemPermissions | null, }; +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 index 204a42764c..29aeceb433 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileFileSystemPermissions.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileFileSystemPermissions.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 { FileSystemSandboxEntry } from "./FileSystemSandboxEntry"; -export type PermissionProfileFileSystemPermissions = { entries: Array, globScanMaxDepth?: number, }; +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 index 9aa130412a..0b25a769a9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileNetworkPermissions.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileNetworkPermissions.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 PermissionProfileNetworkPermissions = { enabled: boolean | null, }; +export type PermissionProfileNetworkPermissions = { enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ReadOnlyAccess.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ReadOnlyAccess.ts deleted file mode 100644 index 78fa04ff37..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ReadOnlyAccess.ts +++ /dev/null @@ -1,6 +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. -import type { AbsolutePathBuf } from "../AbsolutePathBuf"; - -export type ReadOnlyAccess = { "type": "restricted", includePlatformDefaults: boolean, readableRoots: Array, } | { "type": "fullAccess" }; 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/ThreadForkResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts index 5dc6b82a34..b69f1da012 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts @@ -26,8 +26,6 @@ approvalsReviewer: ApprovalsReviewer, */ sandbox: SandboxPolicy, /** - * Canonical active permissions view for this thread when representable. - * This is `null` for external sandbox policies because external - * enforcement cannot be round-tripped as a `PermissionProfile`. + * 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/ThreadResumeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts index d76ad5a58a..5ceec7f3fe 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts @@ -26,8 +26,6 @@ approvalsReviewer: ApprovalsReviewer, */ sandbox: SandboxPolicy, /** - * Canonical active permissions view for this thread when representable. - * This is `null` for external sandbox policies because external - * enforcement cannot be round-tripped as a `PermissionProfile`. + * Canonical active permissions view for this thread. */ permissionProfile: PermissionProfile | null, reasoningEffort: ReasoningEffort | null, }; 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 5a83011abd..61d268afe8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts @@ -26,8 +26,6 @@ approvalsReviewer: ApprovalsReviewer, */ sandbox: SandboxPolicy, /** - * Canonical active permissions view for this thread when representable. - * This is `null` for external sandbox policies because external - * enforcement cannot be round-tripped as a `PermissionProfile`. + * Canonical active permissions view for this thread. */ permissionProfile: PermissionProfile | null, reasoningEffort: ReasoningEffort | 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 be747508ac..0e43b5a4b7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -276,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"; @@ -327,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"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index d55aa79ed1..bb795a6dd1 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -285,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, @@ -1045,6 +1060,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), @@ -1489,7 +1508,7 @@ mod tests { model: "gpt-5".to_string(), model_provider: "openai".to_string(), service_tier: None, - cwd: cwd.clone(), + cwd, instruction_sources: vec![absolute_path("/tmp/AGENTS.md")], approval_policy: v2::AskForApproval::OnFailure, approvals_reviewer: v2::ApprovalsReviewer::User, @@ -1497,7 +1516,6 @@ mod tests { permission_profile: Some( codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy( &codex_protocol::protocol::SandboxPolicy::DangerFullAccess, - cwd.as_path(), ) .into(), ), @@ -1544,22 +1562,7 @@ mod tests { "type": "dangerFullAccess" }, "permissionProfile": { - "network": { - "enabled": true, - }, - "fileSystem": { - "entries": [ - { - "path": { - "type": "special", - "value": { - "kind": "root", - }, - }, - "access": "write", - }, - ], - }, + "type": "disabled" }, "reasoningEffort": null } @@ -2080,6 +2083,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/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index c6090dbe11..019c9fa83e 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -3096,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 { diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index b6726679fb..b7dccc8613 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; @@ -79,7 +83,6 @@ 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; @@ -93,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; @@ -804,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/")] @@ -1355,7 +1355,7 @@ pub struct AdditionalNetworkPermissions { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct PermissionProfileNetworkPermissions { - pub enabled: Option, + pub enabled: bool, } impl From for AdditionalNetworkPermissions { @@ -1374,18 +1374,20 @@ impl From for CoreNetworkPermissions { } } -impl From for PermissionProfileNetworkPermissions { - fn from(value: CoreNetworkPermissions) -> Self { +impl From for PermissionProfileNetworkPermissions { + fn from(value: CoreNetworkSandboxPolicy) -> Self { Self { - enabled: value.enabled, + enabled: value.is_enabled(), } } } -impl From for CoreNetworkPermissions { +impl From for CoreNetworkSandboxPolicy { fn from(value: PermissionProfileNetworkPermissions) -> Self { - Self { - enabled: value.enabled, + if value.enabled { + Self::Enabled + } else { + Self::Restricted } } } @@ -1533,65 +1535,111 @@ impl From for CoreFileSystemSandboxEntry { } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] #[ts(export_to = "v2/")] -pub struct PermissionProfileFileSystemPermissions { - pub entries: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub glob_scan_max_depth: Option, +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: CoreFileSystemPermissions) -> Self { - Self { - entries: value - .entries - .into_iter() - .map(FileSystemSandboxEntry::from) - .collect(), - glob_scan_max_depth: value.glob_scan_max_depth, +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 CoreFileSystemPermissions { +impl From for CoreManagedFileSystemPermissions { fn from(value: PermissionProfileFileSystemPermissions) -> Self { - Self { - entries: value - .entries - .into_iter() - .map(CoreFileSystemSandboxEntry::from) - .collect(), - glob_scan_max_depth: value.glob_scan_max_depth, + 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, Default, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] #[ts(export_to = "v2/")] -pub struct PermissionProfile { - pub network: Option, - pub file_system: Option, +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 { - Self { - network: value.network.map(PermissionProfileNetworkPermissions::from), - file_system: value - .file_system - .map(PermissionProfileFileSystemPermissions::from), + 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 { - Self { - network: value.network.map(CoreNetworkPermissions::from), - file_system: value.file_system.map(CoreFileSystemPermissions::from), + 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(), + }, } } } @@ -1600,12 +1648,13 @@ impl From for CorePermissionProfile { #[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), @@ -1613,7 +1662,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), @@ -1634,7 +1683,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), @@ -1666,54 +1715,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/")] @@ -1722,8 +1724,6 @@ pub enum SandboxPolicy { #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] ReadOnly { - #[serde(default)] - access: ReadOnlyAccess, #[serde(default)] network_access: bool, }, @@ -1739,7 +1739,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)] @@ -1749,19 +1778,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 { @@ -1772,13 +1850,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, @@ -1793,13 +1869,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 { @@ -1810,13 +1882,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, @@ -2015,6 +2085,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)] @@ -3298,6 +3382,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>, @@ -3355,9 +3448,7 @@ pub struct ThreadStartResponse { /// `permissionProfile` when present as the canonical active permissions /// view. pub sandbox: SandboxPolicy, - /// Canonical active permissions view for this thread when representable. - /// This is `null` for external sandbox policies because external - /// enforcement cannot be round-tripped as a `PermissionProfile`. + /// Canonical active permissions view for this thread. #[serde(default)] pub permission_profile: Option, pub reasoning_effort: Option, @@ -3461,9 +3552,7 @@ pub struct ThreadResumeResponse { /// `permissionProfile` when present as the canonical active permissions /// view. pub sandbox: SandboxPolicy, - /// Canonical active permissions view for this thread when representable. - /// This is `null` for external sandbox policies because external - /// enforcement cannot be round-tripped as a `PermissionProfile`. + /// Canonical active permissions view for this thread. #[serde(default)] pub permission_profile: Option, pub reasoning_effort: Option, @@ -3558,9 +3647,7 @@ pub struct ThreadForkResponse { /// `permissionProfile` when present as the canonical active permissions /// view. pub sandbox: SandboxPolicy, - /// Canonical active permissions view for this thread when representable. - /// This is `null` for external sandbox policies because external - /// enforcement cannot be round-tripped as a `PermissionProfile`. + /// Canonical active permissions view for this thread. #[serde(default)] pub permission_profile: Option, pub reasoning_effort: Option, @@ -3661,6 +3748,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/")] @@ -4978,7 +5162,11 @@ pub struct TurnStartParams { #[experimental("turn/start.responsesapiClientMetadata")] #[ts(optional = nullable)] pub responsesapi_client_metadata: Option>, - /// Optional turn-scoped environment selections. + /// 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>, @@ -6180,6 +6368,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/")] @@ -7504,7 +7708,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; @@ -7868,7 +8071,7 @@ mod tests { #[test] fn permission_profile_file_system_permissions_preserves_glob_scan_depth() { - let core_permissions = CoreFileSystemPermissions { + let core_permissions = CoreManagedFileSystemPermissions::Restricted { entries: vec![CoreFileSystemSandboxEntry { path: CoreFileSystemPath::GlobPattern { pattern: "**/*.env".to_string(), @@ -7882,7 +8085,7 @@ mod tests { assert_eq!( permissions, - PermissionProfileFileSystemPermissions { + PermissionProfileFileSystemPermissions::Restricted { entries: vec![FileSystemSandboxEntry { path: FileSystemPath::GlobPattern { pattern: "**/*.env".to_string(), @@ -7893,7 +8096,7 @@ mod tests { } ); assert_eq!( - CoreFileSystemPermissions::from(permissions), + CoreManagedFileSystemPermissions::from(permissions), core_permissions ); } @@ -7901,6 +8104,7 @@ mod tests { #[test] fn permission_profile_file_system_permissions_rejects_zero_glob_scan_depth() { serde_json::from_value::(json!({ + "type": "restricted", "entries": [], "globScanMaxDepth": 0, })) @@ -7954,8 +8158,8 @@ mod tests { ); assert_eq!( - CorePermissionProfile::from(response.permissions), - CorePermissionProfile { + CoreAdditionalPermissionProfile::from(response.permissions), + CoreAdditionalPermissionProfile { network: Some(CoreNetworkPermissions { enabled: Some(true), }), @@ -8709,13 +8913,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, }; @@ -8723,10 +8922,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, } ); @@ -9350,14 +9545,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, @@ -9368,10 +9558,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, @@ -9383,40 +9569,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] diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index cf28cb151c..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, 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 e38e7cb5be..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 } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index e46d785b3a..35df7016c4 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -25,7 +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 frames over `$CODEX_HOME/app-server-control/app-server-control.sock` or a custom socket path without HTTP upgrade +- 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: @@ -39,7 +39,7 @@ Websocket transport is currently experimental and unsupported. Do not rely on it 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 socket uses websocket framing directly over the Unix socket, without an HTTP upgrade handshake. +The proxied stream carries the websocket HTTP Upgrade handshake followed by websocket frames. Security note: @@ -142,7 +142,7 @@ 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"`. For permissions, prefer `permissionProfile`; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissionProfile`. +- `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. @@ -152,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`. @@ -470,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. @@ -541,7 +610,7 @@ 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: @@ -837,7 +906,8 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin "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 "permissionProfile": { // optional; defaults to user config - "fileSystem": { "entries": [ + "type": "managed", + "fileSystem": { "type": "restricted", "entries": [ { "path": { "type": "special", "value": { "kind": "root" } }, "access": "read" }, { "path": { "type": "special", "value": { "kind": "current_working_directory" } }, "access": "write" } ] }, @@ -1232,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/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f6de67bf8a..f0300e8ed1 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -78,6 +78,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; @@ -122,7 +123,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; @@ -222,6 +223,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) @@ -1846,26 +1848,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()) @@ -1967,6 +1955,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, @@ -2342,6 +2344,33 @@ async fn handle_thread_rollback_failed( } } +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, @@ -2719,7 +2748,7 @@ fn request_permissions_response_from_client_result( strict_auto_review: false, }); } - let granted_permissions: CorePermissionProfile = response.permissions.into(); + let granted_permissions: CoreAdditionalPermissionProfile = response.permissions.into(); let permissions = if granted_permissions.is_empty() { CoreRequestPermissionProfile::default() } else { @@ -4204,17 +4233,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 c152fe249a..568db64677 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; @@ -37,7 +38,6 @@ 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; @@ -151,6 +151,16 @@ 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; @@ -220,6 +230,10 @@ 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; @@ -227,6 +241,7 @@ 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; @@ -234,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; @@ -302,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; @@ -314,7 +330,6 @@ 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::FileSystemAccessMode; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::ConversationAudioParams; @@ -329,6 +344,7 @@ 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; @@ -349,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; @@ -401,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; @@ -475,6 +493,9 @@ enum ThreadReadViewError { Internal(String), } +mod thread_goal_handlers; +use self::thread_goal_handlers::api_thread_goal_from_state; + impl Drop for ActiveLogin { fn drop(&mut self) { self.cancel(); @@ -496,6 +517,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, @@ -518,7 +540,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, @@ -656,14 +677,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) @@ -694,14 +726,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( @@ -753,6 +783,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(), @@ -775,6 +808,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( @@ -783,14 +838,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); } @@ -913,6 +966,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; @@ -1286,7 +1351,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 { @@ -1298,31 +1363,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; } } @@ -1385,202 +1435,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( @@ -1603,25 +1594,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( @@ -1631,18 +1619,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. @@ -1656,32 +1657,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(), @@ -1690,12 +1678,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, }; @@ -1712,6 +1700,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. { @@ -1741,23 +1766,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; } } @@ -1840,77 +1866,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 - | CoreAuthMode::AgentIdentity => { - 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( @@ -1918,16 +1921,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( @@ -1943,7 +1941,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(), @@ -1998,7 +1996,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(), @@ -2055,18 +2053,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 { @@ -2086,43 +2090,25 @@ impl CodexMessageProcessor { permission_profile, } = params; if sandbox_policy.is_some() && permission_profile.is_some() { - self.send_invalid_request_error( - request_id, - "`permissionProfile` cannot be combined with `sandboxPolicy`".to_string(), - ) - .await; - return; + 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)); @@ -2146,15 +2132,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, @@ -2164,7 +2144,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, @@ -2174,13 +2154,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, @@ -2228,79 +2204,52 @@ impl CodexMessageProcessor { arg0: None, }; - let ( - effective_policy, - effective_file_system_sandbox_policy, - effective_network_sandbox_policy, - ) = if let Some(permission_profile) = permission_profile { + let effective_permission_profile = if let Some(permission_profile) = permission_profile { let permission_profile = codex_protocol::models::PermissionProfile::from(permission_profile); - let sandbox_policy = match permission_profile.to_legacy_sandbox_policy(&sandbox_cwd) { - Ok(sandbox_policy) => sandbox_policy, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid permission profile: {err}"), - data: None, - }; - self.outgoing.send_error(request, error).await; - return; - } - }; - match self - .config + 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 - .sandbox_policy - .can_set(&sandbox_policy) - { - Ok(()) => { - let (mut file_system_sandbox_policy, network_sandbox_policy) = - permission_profile.to_runtime_permissions(); - Self::preserve_configured_deny_read_restrictions( - &mut file_system_sandbox_policy, - &self.config.permissions.file_system_sandbox_policy, - ); - ( - sandbox_policy, - file_system_sandbox_policy, - network_sandbox_policy, - ) - } - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid permission profile: {err}"), - data: None, - }; - self.outgoing.send_error(request, error).await; - return; - } - } + .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()) { - 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; - } - } + 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.sandbox_policy.get().clone(), - self.config.permissions.file_system_sandbox_policy.clone(), - self.config.permissions.network_sandbox_policy, - ) + self.config.permissions.permission_profile() }; let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); @@ -2310,75 +2259,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, ) { - if file_system_sandbox_policy.glob_scan_max_depth.is_none() { - file_system_sandbox_policy.glob_scan_max_depth = - configured_file_system_sandbox_policy.glob_scan_max_depth; - } - - for deny_entry in configured_file_system_sandbox_policy - .entries - .iter() - .filter(|entry| entry.access == FileSystemAccessMode::None) - { - if !file_system_sandbox_policy - .entries - .iter() - .any(|entry| entry == deny_entry) - { - file_system_sandbox_policy.entries.push(deny_entry.clone()); - } - } + file_system_sandbox_policy + .preserve_deny_read_restrictions_from(configured_file_system_sandbox_policy); } async fn command_exec_write( @@ -2386,14 +2300,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( @@ -2401,14 +2312,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( @@ -2416,14 +2324,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( @@ -2453,6 +2358,7 @@ impl CodexMessageProcessor { personality, ephemeral, session_start_source, + environments, persist_extended_history, } = params; if sandbox.is_some() && permission_profile.is_some() { @@ -2463,6 +2369,24 @@ impl CodexMessageProcessor { .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.send_invalid_request_error(request_id, environment_selection_error_message(err)) + .await; + return; + } let mut typesafe_overrides = self.build_thread_config_overrides( model, model_provider, @@ -2483,7 +2407,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(), @@ -2501,6 +2424,7 @@ impl CodexMessageProcessor { typesafe_overrides, dynamic_tools, session_start_source, + environments, persist_extended_history, service_name, experimental_raw_events, @@ -2575,6 +2499,7 @@ 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, @@ -2603,16 +2528,14 @@ impl CodexMessageProcessor { // 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 - || matches!( - config.permissions.sandbox_policy.get(), - codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } - | codex_protocol::protocol::SandboxPolicy::DangerFullAccess - | codex_protocol::protocol::SandboxPolicy::ExternalSandbox { .. } - )) + && (requested_permissions_trust_project || effective_permissions_trust_project) { let trust_target = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &config.cwd) .await @@ -2674,6 +2597,11 @@ impl CodexMessageProcessor { } 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() @@ -2705,19 +2633,20 @@ impl CodexMessageProcessor { match listener_task_context .thread_manager - .start_thread_with_tools_and_service_name( + .start_thread_with_tools_and_service_name(StartThreadWithToolsOptions { config, - match session_start_source + 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, }, - core_dynamic_tools, + dynamic_tools: core_dynamic_tools, persist_extended_history, - service_name, - request_trace, - ) + 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", @@ -2800,10 +2729,8 @@ impl CodexMessageProcessor { /*has_in_progress_turn*/ false, ); - let permission_profile = thread_response_permission_profile( - &config_snapshot.sandbox_policy, - config_snapshot.permission_profile, - ); + let permission_profile = + thread_response_permission_profile(config_snapshot.permission_profile); let response = ThreadStartResponse { thread: thread.clone(), @@ -2818,17 +2745,15 @@ impl CodexMessageProcessor { permission_profile, 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(), - }, - ); - } + 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 @@ -2839,7 +2764,7 @@ impl CodexMessageProcessor { )) .await; - let notif = ThreadStartedNotification { thread }; + let notif = thread_started_notification(thread); listener_task_context .outgoing .send_server_notification(ServerNotification::ThreadStarted(notif)) @@ -2849,6 +2774,17 @@ impl CodexMessageProcessor { )) .await; } + Err(CodexErr::InvalidRequest(message)) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message, + data: None, + }; + listener_task_context + .outgoing + .send_error(request_id, error) + .await; + } Err(err) => { let error = JSONRPCErrorError { code: INTERNAL_ERROR_CODE, @@ -4478,22 +4414,22 @@ impl CodexMessageProcessor { } = params; let include_turns = !exclude_turns; - let thread_history = if let Some(history) = history { + let (thread_history, resume_source_thread) = if let Some(history) = history { let Some(thread_history) = self .resume_thread_from_history(request_id.clone(), history.as_slice()) .await else { return; }; - thread_history + (thread_history, None) } else { - let Some(thread_history) = self + let Some((thread_history, stored_thread)) = self .resume_thread_from_rollout(request_id.clone(), &thread_id, path.as_ref()) .await else { return; }; - thread_history + (thread_history, Some(stored_thread)) }; let history_cwd = thread_history.session_cwd(); @@ -4510,13 +4446,12 @@ impl CodexMessageProcessor { 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 @@ -4581,7 +4516,7 @@ impl CodexMessageProcessor { codex_thread.as_ref(), &response_history, rollout_path.as_path(), - persisted_resume_metadata.as_ref(), + resume_source_thread, include_turns, ) .await @@ -4608,7 +4543,6 @@ impl CodexMessageProcessor { /*has_live_in_progress_turn*/ false, ); let permission_profile = thread_response_permission_profile( - &session_configured.sandbox_policy, codex_thread.config_snapshot().await.permission_profile, ); @@ -4625,15 +4559,13 @@ impl CodexMessageProcessor { 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 = include_turns.then(|| response.thread.clone()); @@ -4658,6 +4590,14 @@ impl CodexMessageProcessor { ) .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 { @@ -4708,77 +4648,58 @@ impl CodexMessageProcessor { return true; } - 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() + active_path.display() ), ) .await; return true; } + let Some(source_thread) = self + .read_stored_thread_for_resume( + request_id.clone(), + ¶ms.thread_id, + params.path.as_ref(), + /*include_history*/ true, + ) + .await + else { + return true; + }; + if source_thread.thread_id != existing_thread_id { + self.send_invalid_request_error( + request_id, + format!( + "cannot resume running thread {existing_thread_id} from source thread {}", + source_thread.thread_id + ), + ) + .await; + return true; + } + let Some(history_items) = source_thread + .history + .as_ref() + .map(|history| history.items.clone()) + else { + self.send_internal_error( + request_id, + format!("thread {existing_thread_id} did not include persisted history"), + ) + .await; + return true; + }; + let thread_state = self .thread_state_manager .thread_state(existing_thread_id) @@ -4805,18 +4726,15 @@ impl CodexMessageProcessor { mismatch_details.join("; ") ); } - 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 + 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) => { @@ -4824,6 +4742,10 @@ impl CodexMessageProcessor { return true; } }; + 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 listener_command_tx = { let thread_state = thread_state.lock().await; @@ -4841,13 +4763,26 @@ impl CodexMessageProcessor { return true; }; + 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, }), ); @@ -4860,6 +4795,7 @@ impl CodexMessageProcessor { data: None, }; self.outgoing.send_error(request_id, err).await; + return true; } return true; } @@ -4890,57 +4826,133 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, thread_id: &str, path: Option<&PathBuf>, - ) -> Option { - let rollout_path = if let Some(path) = path { - path.clone() + ) -> Option<(InitialHistory, StoredThread)> { + match self + .read_stored_thread_for_resume( + request_id.clone(), + thread_id, + path, + /*include_history*/ true, + ) + .await + { + Some(stored_thread) => self + .stored_thread_to_initial_history(request_id, &stored_thread) + .await + .map(|history| (history, stored_thread)), + None => None, + } + } + + async fn read_stored_thread_for_resume( + &self, + request_id: ConnectionRequestId, + thread_id: &str, + path: Option<&PathBuf>, + include_history: bool, + ) -> Option { + 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; + self.send_invalid_request_error( + request_id, + format!("invalid thread id: {err}"), + ) + .await; return None; } }; - - 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), + match result { + Ok(thread) => Some(thread), Err(err) => { - self.send_invalid_request_error( + self.outgoing + .send_error(request_id, thread_store_resume_read_error(err)) + .await; + None + } + } + } + + async fn stored_thread_to_initial_history( + &self, + request_id: ConnectionRequestId, + stored_thread: &StoredThread, + ) -> Option { + let thread_id = stored_thread.thread_id; + let history = match stored_thread.history.as_ref() { + Some(history) => history.items.clone(), + None => { + self.send_internal_error( request_id, - format!("failed to load rollout `{}`: {err}", rollout_path.display()), + format!("thread {thread_id} did not include persisted history"), ) .await; + return None; + } + }; + Some(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, + request_id: ConnectionRequestId, + thread_store: &dyn ThreadStore, + thread_id: ThreadId, + include_history: bool, + ) -> Option { + match thread_store + .read_thread(StoreReadThreadParams { + thread_id, + include_archived: true, + include_history, + }) + .await + { + Ok(thread) => Some(thread), + Err(err) => { + self.outgoing + .send_error(request_id, thread_store_resume_read_error(err)) + .await; None } } @@ -4952,20 +4964,42 @@ impl CodexMessageProcessor { thread: &CodexThread, thread_history: &InitialHistory, rollout_path: &Path, - 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(), - config_snapshot.model_provider_id.as_str(), - 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 mut thread = build_thread_from_snapshot( @@ -5031,50 +5065,31 @@ impl CodexMessageProcessor { return; } - 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; - } - }; - - match find_thread_path_by_id_str( - &self.config.codex_home, - &existing_thread_id.to_string(), + let Some(source_thread) = self + .read_stored_thread_for_resume( + request_id.clone(), + &thread_id, + path.as_ref(), + /*include_history*/ true, ) .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; - } - } + else { + return; }; - - let history_cwd = - read_history_cwd_from_state_db(&self.config, source_thread_id, rollout_path.as_path()) - .await; + let source_thread_id = source_thread.thread_id; + let Some(history_items) = source_thread + .history + .as_ref() + .map(|history| history.items.clone()) + else { + self.send_internal_error( + request_id, + format!("thread {source_thread_id} did not include persisted history"), + ) + .await; + return; + }; + let history_cwd = Some(source_thread.cwd.clone()); // Persist Windows sandbox mode. let mut cli_overrides = cli_overrides.unwrap_or_default(); @@ -5129,6 +5144,7 @@ impl CodexMessageProcessor { 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 NewThread { thread_id, @@ -5137,10 +5153,14 @@ impl CodexMessageProcessor { .. } = match self .thread_manager - .fork_thread( + .fork_thread_from_history( ForkSnapshot::Interrupted, config, - rollout_path.clone(), + 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, ) @@ -5152,7 +5172,7 @@ impl CodexMessageProcessor { CodexErr::Io(_) | CodexErr::Json(_) => { self.send_invalid_request_error( request_id, - format!("failed to load rollout `{}`: {err}", rollout_path.display()), + format!("failed to load thread {source_thread_id}: {err}"), ) .await; } @@ -5186,25 +5206,33 @@ impl CodexMessageProcessor { ); // Persistent forks materialize their own rollout immediately. Ephemeral forks stay - // pathless, so they rebuild their visible history from the copied source rollout instead. + // 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() { - match read_summary_from_rollout( - fork_rollout_path.as_path(), - fallback_model_provider.as_str(), - ) - .await + let Some(stored_thread) = self + .read_stored_thread_for_new_fork( + request_id.clone(), + fork_thread_store.as_ref(), + thread_id, + include_turns, + ) + .await + else { + return; + }; + match self + .stored_thread_to_api_thread( + stored_thread, + fallback_model_provider.as_str(), + include_turns, + ) + .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) => { + Ok(thread) => thread, + Err(message) => { self.send_internal_error( request_id, format!( - "failed to load rollout `{}` for thread {thread_id}: {err}", + "failed to load rollout `{}` for thread {thread_id}: {message}", fork_rollout_path.display() ), ) @@ -5217,30 +5245,8 @@ impl CodexMessageProcessor { // 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()); + thread.forked_from_id = Some(source_thread_id.to_string()); if include_turns && let Err(message) = populate_thread_turns( &mut thread, @@ -5255,19 +5261,6 @@ impl CodexMessageProcessor { thread }; - if let Some(fork_rollout_path) = session_configured.rollout_path.as_ref() - && include_turns - && 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; @@ -5279,7 +5272,6 @@ impl CodexMessageProcessor { /*has_in_progress_turn*/ false, ); let permission_profile = thread_response_permission_profile( - &session_configured.sandbox_policy, forked_thread.config_snapshot().await.permission_profile, ); @@ -5296,15 +5288,13 @@ impl CodexMessageProcessor { 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::ThreadFork { - request_id: request_id.request_id.clone(), - response: response.clone(), - }, - ); - } + 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()); @@ -5317,11 +5307,10 @@ impl CodexMessageProcessor { { Some(turn_id) } else { - latest_token_usage_turn_id_from_rollout_path( - rollout_path.as_path(), + latest_token_usage_turn_id_from_rollout_items( + &history_items, token_usage_thread.turns.as_slice(), ) - .await }; // Mirror the resume contract for forks: the new thread is usable as soon // as the response arrives, so restored usage must follow immediately. @@ -5336,7 +5325,7 @@ impl CodexMessageProcessor { .await; } - let notif = ThreadStartedNotification { thread }; + let notif = thread_started_notification(thread); self.outgoing .send_server_notification(ServerNotification::ThreadStarted(notif)) .await; @@ -5364,14 +5353,13 @@ 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; + self.send_invalid_request_error( + request_id, + "rollout path queries are only supported with the local thread store" + .to_string(), + ) + .await; + return; }; local_thread_store @@ -5606,6 +5594,10 @@ impl CodexMessageProcessor { return; } }; + 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() @@ -5640,7 +5632,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, } }) @@ -5909,8 +5903,8 @@ impl CodexMessageProcessor { let environment_manager = self.thread_manager.environment_manager(); let runtime_environment = match environment_manager.default_environment() { Some(environment) => { - // Status listing has no turn cwd. This fallback is used by - // stdio MCPs whose config omits `cwd`. + // Status listing has no turn cwd. This fallback is used only + // by executor-backed stdio MCPs whose config omits `cwd`. McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()) } None => McpRuntimeEnvironment::new( @@ -6184,6 +6178,22 @@ impl CodexMessageProcessor { }); } + 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; + } + } + } + async fn send_invalid_request_error(&self, request_id: ConnectionRequestId, message: String) { let error = JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, @@ -6193,6 +6203,15 @@ impl CodexMessageProcessor { self.outgoing.send_error(request_id, error).await; } + 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; + } + fn input_too_large_error(actual_chars: usize) -> JSONRPCErrorError { JSONRPCErrorError { code: INVALID_PARAMS_ERROR_CODE, @@ -6215,41 +6234,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, @@ -6328,34 +6312,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 { @@ -6363,9 +6346,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) { @@ -6414,7 +6395,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( @@ -6443,6 +6440,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, @@ -6452,15 +6459,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, }; @@ -6514,7 +6513,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); } @@ -6524,25 +6523,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" + ))); } }; @@ -6552,26 +6539,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)); } } @@ -6601,27 +6576,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, @@ -6646,17 +6620,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 @@ -6665,13 +6635,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 @@ -6721,7 +6689,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( @@ -6746,9 +6714,7 @@ impl CodexMessageProcessor { errors, }); } - self.outgoing - .send_response(request_id, SkillsListResponse { data }) - .await; + Ok(SkillsListResponse { data }) } async fn marketplace_remove( &self, @@ -6761,27 +6727,16 @@ 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 marketplace_upgrade( @@ -6789,53 +6744,38 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: MarketplaceUpgradeParams, ) { - 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 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 MarketplaceUpgradeParams { marketplace_name } = params; - let result = tokio::task::spawn_blocking(move || { + let outcome = tokio::task::spawn_blocking(move || { plugins_manager .upgrade_configured_marketplaces_for_config(&config, marketplace_name.as_deref()) }) - .await; + .await + .map_err(|err| internal_error(format!("failed to upgrade marketplaces: {err}")))? + .map_err(invalid_request)?; - match result { - Ok(Ok(outcome)) => { - self.outgoing - .send_response( - request_id, - 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(), - }, - ) - .await; - } - Ok(Err(message)) => { - self.send_invalid_request_error(request_id, message).await; - } - Err(err) => { - self.send_internal_error( - request_id, - format!("failed to upgrade marketplaces: {err}"), - ) - .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) { @@ -6847,28 +6787,17 @@ impl CodexMessageProcessor { 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; - } - } + .await + .map(|outcome| MarketplaceAddResponse { + marketplace_name: outcome.marketplace_name, + 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( @@ -6876,6 +6805,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, @@ -6890,43 +6827,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; - } - } + SkillsConfigWriteResponse { + effective_enabled: enabled, + } + }) + .map_err(|err| internal_error(format!("failed to update skill settings: {err}"))) } async fn turn_start( @@ -6971,15 +6889,25 @@ impl CodexMessageProcessor { let collaboration_mode = params.collaboration_mode.map(|mode| { self.normalize_turn_start_collaboration_mode(mode, collaboration_modes_config) }); - let environments = params.environments.map(|environments| { - environments - .into_iter() - .map(|environment| TurnEnvironmentSelection { - environment_id: environment.environment_id, - cwd: environment.cwd, - }) - .collect() - }); + 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() + && let Err(err) = self + .thread_manager + .validate_environment_selections(environments) + { + self.send_invalid_request_error(request_id, environment_selection_error_message(err)) + .await; + return; + } // Map v2 input items to core input items. let mapped_items: Vec = params @@ -7100,15 +7028,13 @@ impl CodexMessageProcessor { }; 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(), - }, - ); - } + 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) => { @@ -7234,15 +7160,13 @@ impl CodexMessageProcessor { { 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(), - }, - ); - } + 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) => { @@ -7679,7 +7603,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; @@ -7778,11 +7702,6 @@ impl CodexMessageProcessor { 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, @@ -7796,10 +7715,40 @@ impl CodexMessageProcessor { // 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)); + let is_running = matches!(thread.agent_status().await, AgentStatus::Running); + let interrupt_outcome = { + let mut thread_state = thread_state.lock().await; + if let Some(active_turn) = thread_state.active_turn_snapshot() { + if active_turn.id != turn_id { + Err(format!( + "expected active turn id {turn_id} but found {}", + active_turn.id + )) + } else { + thread_state + .pending_interrupts + .push((request_id.clone(), ApiVersion::V2)); + Ok(()) + } + } else if thread_state.last_terminal_turn_id.as_deref() == Some(turn_id.as_str()) { + Err("no active turn to interrupt".to_string()) + } else if is_running { + thread_state + .pending_interrupts + .push((request_id.clone(), ApiVersion::V2)); + Ok(()) + } else { + Err("no active turn to interrupt".to_string()) + } + }; + if let Err(message) = interrupt_outcome { + self.send_invalid_request_error(request_id, message).await; + return; + } + + self.outgoing + .record_request_turn_id(&request_id, &turn_id) + .await; } // Submit the interrupt. Turn interrupts respond upon TurnAborted; startup @@ -7850,7 +7799,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(), @@ -7968,7 +7916,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(), @@ -8017,7 +7964,6 @@ impl CodexMessageProcessor { thread_state_manager, pending_thread_unloads, analytics_events_client: _, - general_analytics_enabled: _, thread_watch_manager, fallback_model_provider, codex_home, @@ -8062,7 +8008,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 @@ -8093,9 +8039,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(), @@ -8524,7 +8468,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(), @@ -8666,6 +8612,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, @@ -8722,7 +8691,7 @@ async fn handle_pending_thread_resume_request( if pending.include_turns && let Err(message) = populate_thread_turns( &mut thread, - ThreadTurnSource::RolloutPath(pending.rollout_path.as_path()), + ThreadTurnSource::HistoryItems(&pending.history_items), active_turn.as_ref(), ) .await @@ -8781,6 +8750,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, @@ -8794,8 +8769,7 @@ async fn handle_pending_thread_resume_request( .. } = pending.config_snapshot; let instruction_sources = pending.instruction_sources; - let permission_profile = - thread_response_permission_profile(&sandbox_policy, permission_profile); + let permission_profile = thread_response_permission_profile(permission_profile); let response = ThreadResumeResponse { thread, @@ -8815,11 +8789,10 @@ async fn handle_pending_thread_resume_request( // 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_path( - pending.rollout_path.as_path(), + let token_usage_turn_id = latest_token_usage_turn_id_from_rollout_items( + &pending.history_items, token_usage_thread.turns.as_slice(), - ) - .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( @@ -8832,13 +8805,64 @@ async fn handle_pending_thread_resume_request( ) .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]), } @@ -8848,18 +8872,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 { @@ -9047,6 +9059,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( @@ -9283,36 +9296,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, @@ -9370,6 +9353,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()) @@ -9406,6 +9410,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, @@ -9825,45 +9849,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 @@ -9872,10 +9857,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() @@ -9925,17 +9906,9 @@ fn with_thread_spawn_agent_metadata( } fn thread_response_permission_profile( - sandbox_policy: &codex_protocol::protocol::SandboxPolicy, permission_profile: codex_protocol::models::PermissionProfile, ) -> Option { - match sandbox_policy { - codex_protocol::protocol::SandboxPolicy::DangerFullAccess - | codex_protocol::protocol::SandboxPolicy::ReadOnly { .. } - | codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } => { - Some(permission_profile.into()) - } - codex_protocol::protocol::SandboxPolicy::ExternalSandbox { .. } => None, - } + Some(permission_profile.into()) } fn requested_permissions_trust_project(overrides: &ConfigOverrides, cwd: &Path) -> bool { @@ -9952,18 +9925,20 @@ fn requested_permissions_trust_project(overrides: &ConfigOverrides, cwd: &Path) overrides .permission_profile .as_ref() - .is_some_and(|profile| { - profile - .to_legacy_sandbox_policy(cwd) - .is_ok_and(|sandbox_policy| { - matches!( - sandbox_policy, - codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } - | codex_protocol::protocol::SandboxPolicy::DangerFullAccess - | codex_protocol::protocol::SandboxPolicy::ExternalSandbox { .. } - ) - }) - }) + .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> { @@ -10013,6 +9988,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, @@ -10243,17 +10223,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; @@ -10457,45 +10439,43 @@ mod tests { } #[test] - fn thread_response_permission_profile_omits_external_sandbox() { - let cwd = test_path_buf("/tmp").abs(); - let profile = codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy( - &SandboxPolicy::DangerFullAccess, - cwd.as_path(), - ); + 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( - &SandboxPolicy::ExternalSandbox { - network_access: codex_protocol::protocol::NetworkAccess::Restricted, - }, - profile.clone(), - ), - None + thread_response_permission_profile(external_profile.clone()), + Some(external_profile.into()) ); assert_eq!( - thread_response_permission_profile(&SandboxPolicy::DangerFullAccess, profile.clone()), - Some(profile.into()) + 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::from_legacy_sandbox_policy( - &SandboxPolicy::DangerFullAccess, - cwd.as_path(), - ); - let workspace_write_profile = - codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy( - &SandboxPolicy::new_workspace_write_policy(), - cwd.as_path(), - ); - let read_only_profile = - codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - cwd.as_path(), + 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( @@ -10512,6 +10492,13 @@ mod tests { }, 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), @@ -10704,11 +10691,7 @@ mod tests { approval_policy: codex_protocol::protocol::AskForApproval::OnRequest, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, - permission_profile: - codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy( - &codex_protocol::protocol::SandboxPolicy::DangerFullAccess, - cwd.as_path(), - ), + permission_profile: codex_protocol::models::PermissionProfile::Disabled, cwd, ephemeral: false, reasoning_effort: None, @@ -10774,6 +10757,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([( @@ -10804,6 +10791,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([( @@ -10832,6 +10820,7 @@ mod tests { ); assert_eq!(typesafe_overrides.model, None); + assert_eq!(typesafe_overrides.model_provider, None); assert_eq!( request_overrides, Some(HashMap::from([( @@ -10883,6 +10872,7 @@ mod tests { ); assert_eq!(typesafe_overrides.model, None); + assert_eq!(typesafe_overrides.model_provider, None); assert_eq!( request_overrides, Some(HashMap::from([( @@ -10907,6 +10897,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(()) } @@ -11233,14 +11227,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 index 072276eb21..e1d0fffad3 100644 --- a/codex-rs/app-server/src/codex_message_processor/plugins.rs +++ b/codex-rs/app-server/src/codex_message_processor/plugins.rs @@ -1,4 +1,7 @@ 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( @@ -6,32 +9,35 @@ impl CodexMessageProcessor { 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 = match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => config, - Err(err) => { - self.outgoing.send_error(request_id, err).await; - return; - } + 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) { - self.outgoing - .send_response( - request_id, - PluginListResponse { - marketplaces: Vec::new(), - marketplace_load_errors: Vec::new(), - featured_plugin_ids: Vec::new(), - }, - ) - .await; - return; + 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 auth = self.auth_manager.auth().await; let config_for_marketplace_listing = config.clone(); let plugins_manager_for_marketplace_listing = plugins_manager.clone(); @@ -83,18 +89,11 @@ impl CodexMessageProcessor { .await { Ok(Ok(outcome)) => outcome, - Ok(Err(err)) => { - self.send_marketplace_error(request_id, err, "list marketplace plugins") - .await; - return; - } + Ok(Err(err)) => return Err(Self::marketplace_error(err, "list marketplace plugins")), Err(err) => { - self.send_internal_error( - request_id, - format!("failed to list marketplace plugins: {err}"), - ) - .await; - return; + return Err(internal_error(format!( + "failed to list marketplace plugins: {err}" + ))); } }; @@ -157,16 +156,11 @@ impl CodexMessageProcessor { Vec::new() }; - self.outgoing - .send_response( - request_id, - PluginListResponse { - marketplaces: data, - marketplace_load_errors, - featured_plugin_ids, - }, - ) - .await; + Ok(PluginListResponse { + marketplaces: data, + marketplace_load_errors, + featured_plugin_ids, + }) } pub(super) async fn plugin_read( @@ -174,6 +168,14 @@ impl CodexMessageProcessor { 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, @@ -184,30 +186,16 @@ impl CodexMessageProcessor { (Some(marketplace_path), None) => Ok(marketplace_path), (None, Some(remote_marketplace_name)) => Err(remote_marketplace_name), (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; + 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 = match self.load_latest_config(config_cwd).await { - Ok(config) => config, - Err(err) => { - self.outgoing.send_error(request_id, err).await; - return; - } - }; + let config = self.load_latest_config(config_cwd).await?; let plugin = match read_source { Ok(marketplace_path) => { @@ -215,17 +203,10 @@ impl CodexMessageProcessor { plugin_name, marketplace_path, }; - let outcome = match plugins_manager + let outcome = 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; - } - }; + .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, @@ -270,19 +251,9 @@ impl CodexMessageProcessor { if !config.features.enabled(Feature::Plugins) || !config.features.enabled(Feature::RemotePlugin) { - self.outgoing - .send_error( - request_id, - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!( - "remote plugin read is not enabled for marketplace {remote_marketplace_name}" - ), - data: None, - }, - ) - .await; - return; + 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 { @@ -293,36 +264,20 @@ impl CodexMessageProcessor { .chars() .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '~') { - self.send_invalid_request_error( - request_id, - "invalid remote plugin id: only ASCII letters, digits, `_`, `-`, and `~` are allowed" - .to_string(), - ) - .await; - return; + return Err(invalid_request( + "invalid remote plugin id: only ASCII letters, digits, `_`, `-`, and `~` are allowed", + )); } - let remote_detail = match codex_core_plugins::remote::fetch_remote_plugin_detail( + let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail( &remote_plugin_service_config, auth.as_ref(), &remote_marketplace_name, &plugin_name, ) .await - { - Ok(remote_detail) => remote_detail, - Err(err) => { - self.outgoing - .send_error( - request_id, - remote_plugin_catalog_error_to_jsonrpc( - err, - "read remote plugin details", - ), - ) - .await; - return; - } - }; + .map_err(|err| { + remote_plugin_catalog_error_to_jsonrpc(err, "read remote plugin details") + })?; let plugin_apps = remote_detail .app_ids .iter() @@ -340,9 +295,7 @@ impl CodexMessageProcessor { } }; - self.outgoing - .send_response(request_id, PluginReadResponse { plugin }) - .await; + Ok(PluginReadResponse { plugin }) } pub(super) async fn plugin_install( @@ -350,6 +303,14 @@ impl CodexMessageProcessor { 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, @@ -358,35 +319,28 @@ impl CodexMessageProcessor { 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, - }, - ) + return self + .remote_plugin_install_response(remote_marketplace_name, plugin_name) .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; + 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 { @@ -394,152 +348,188 @@ impl CodexMessageProcessor { 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; - } - - 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; - } + 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) => { - 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; - } - } + 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( @@ -547,59 +537,82 @@ impl CodexMessageProcessor { 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(); - let uninstall_result = plugins_manager.uninstall_plugin(plugin_id).await; + plugins_manager + .uninstall_plugin(plugin_id) + .await + .map_err(Self::plugin_uninstall_error)?; + self.clear_plugin_related_caches(); + Ok(PluginUninstallResponse {}) + } - 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; - } + fn plugin_install_error(err: CorePluginInstallError) -> JSONRPCErrorError { + if err.is_invalid_request() { + return invalid_request(err.to_string()); + } - 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"); - } - } + 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}")), } } } @@ -686,7 +699,9 @@ fn remote_plugin_catalog_error_to_jsonrpc( RemotePluginCatalogError::AuthToken(_) | RemotePluginCatalogError::Request { .. } | RemotePluginCatalogError::UnexpectedStatus { .. } - | RemotePluginCatalogError::Decode { .. } => JSONRPCErrorError { + | 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/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index 1414cefb61..fc4761c1c6 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, ) } @@ -842,10 +811,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 { @@ -857,7 +823,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()), @@ -865,9 +831,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_api.rs b/codex-rs/app-server/src/config_api.rs index ce0ea34069..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; @@ -22,15 +23,15 @@ 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::HookEventsToml; -use codex_core::config_loader::HookHandlerConfig as CoreHookHandlerConfig; -use codex_core::config_loader::ManagedHooksRequirementsToml; -use codex_core::config_loader::MatcherGroup as CoreMatcherGroup; -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; @@ -99,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}" + )) }) } @@ -197,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) { @@ -215,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() { @@ -232,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; @@ -388,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 { @@ -438,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, } } @@ -468,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 { @@ -491,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; @@ -539,11 +516,9 @@ mod tests { 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), @@ -809,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 43dd190045..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; @@ -33,7 +33,6 @@ pub(crate) struct ConfigManager { cloud_requirements: Arc>, arg0_paths: Arg0DispatchPaths, thread_config_loader: Arc>>, - host_name: Option, } 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, @@ -74,7 +52,6 @@ impl ConfigManager { cloud_requirements: Arc::new(RwLock::new(cloud_requirements)), arg0_paths, thread_config_loader: Arc::new(RwLock::new(thread_config_loader)), - host_name, } } @@ -229,7 +206,6 @@ impl ConfigManager { .fallback_cwd(fallback_cwd) .cloud_requirements(self.current_cloud_requirements()) .thread_config_loader(self.current_thread_config_loader()) - .host_name(self.host_name.clone()) .build() .await?; self.apply_runtime_feature_enablement(&mut config); @@ -257,7 +233,6 @@ impl ConfigManager { self.loader_overrides.clone(), self.current_cloud_requirements(), thread_config_loader.as_ref(), - self.host_name.as_deref(), ) .await } @@ -285,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, ) } @@ -305,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 0d76096de0..47248451a2 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 12c5baf3e8..27ac7453a5 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -78,10 +78,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; @@ -367,7 +367,8 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { let runtime_handle = tokio::spawn(async move { let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(channel_capacity); 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 analytics_events_client = analytics_events_client_from_config(Arc::clone(&auth_manager), args.config.as_ref()); let outgoing_message_sender = Arc::new(OutgoingMessageSender::new( @@ -422,6 +423,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 3eaf66d212..3aecfd6120 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -1,12 +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; @@ -43,11 +43,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; @@ -364,6 +364,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, @@ -372,6 +391,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( @@ -428,7 +471,7 @@ pub async fn run_main_with_transport( 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) => { @@ -477,7 +520,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, @@ -590,7 +633,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 { @@ -670,7 +713,7 @@ pub async fn run_main_with_transport( let processor_handle = tokio::spawn({ 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 analytics_events_client = analytics_events_client_from_config(Arc::clone(&auth_manager), &config); let outgoing_message_sender = Arc::new(OutgoingMessageSender::new( @@ -691,6 +734,7 @@ pub async fn run_main_with_transport( auth_manager, 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(); diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index e379160933..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; @@ -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 3c9b414b1d..d9cc98cf59 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, @@ -261,6 +249,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 { @@ -281,6 +270,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(), @@ -312,17 +302,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( @@ -383,43 +376,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; @@ -456,14 +434,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; @@ -597,7 +579,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 @@ -607,13 +589,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`. @@ -641,17 +617,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}"); @@ -667,13 +635,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 @@ -694,14 +656,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); @@ -728,7 +688,7 @@ impl MessageProcessor { .connection_initialized(connection_id) .await; } - return; + return Ok(()); } self.dispatch_initialized_client_request( @@ -737,7 +697,7 @@ impl MessageProcessor { session, request_context, ) - .await; + .await } async fn dispatch_initialized_client_request( @@ -746,32 +706,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, @@ -792,7 +739,7 @@ impl MessageProcessor { client_version, device_key_requests_allowed, ) - .await; + .await } async fn handle_initialized_client_request( @@ -803,66 +750,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; @@ -871,136 +800,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 @@ -1018,13 +916,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( @@ -1081,7 +973,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; } @@ -1169,271 +1061,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 d99fbea712..ed8762370a 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -28,10 +28,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; @@ -128,7 +128,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(); @@ -258,7 +258,7 @@ async fn build_test_config(codex_home: &Path, server_uri: &str) -> Result, ) -> ( Arc, @@ -266,7 +266,7 @@ fn build_test_processor( ) { let (outgoing_tx, outgoing_rx) = mpsc::channel(16); 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 analytics_events_client = analytics_events_client_from_config(Arc::clone(&auth_manager), config.as_ref()); let outgoing = Arc::new(OutgoingMessageSender::new( @@ -295,6 +295,7 @@ fn build_test_processor( auth_manager, rpc_transport: AppServerRpcTransport::Stdio, remote_control_handle: None, + plugin_startup_tasks: crate::PluginStartupTasks::Start, })); (processor, outgoing_rx) } @@ -731,77 +732,79 @@ async fn remote_control_origin_rejects_device_key_requests() -> Result<()> { Ok(()) } -#[tokio::test(flavor = "current_thread")] +#[test] #[serial(app_server_tracing)] -async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { - let mut harness = TracingHarness::new().await?; - let thread_start_response = harness.start_thread(/*request_id*/ 2, /*trace*/ None).await; - let thread_id = thread_start_response.thread.id.clone(); +fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { + run_current_thread_test_with_stack("turn_start_jsonrpc_span_parents_core_turn_spans", async { + let mut harness = TracingHarness::new().await?; + let thread_start_response = harness.start_thread(/*request_id*/ 2, /*trace*/ None).await; + let thread_id = thread_start_response.thread.id.clone(); - harness.reset_tracing(); + harness.reset_tracing(); - let RemoteTrace { - trace_id: remote_trace_id, - parent_span_id: remote_parent_span_id, - context: remote_trace, - } = RemoteTrace::new("00000000000000000000000000000077", "0000000000000088"); - let turn_start_response: TurnStartResponse = harness - .request( - ClientRequest::TurnStart { - request_id: RequestId::Integer(3), - params: TurnStartParams { - environments: None, - thread_id, - input: vec![UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }], - responsesapi_client_metadata: None, - cwd: None, - approval_policy: None, - sandbox_policy: None, - permission_profile: None, - approvals_reviewer: None, - model: None, - service_tier: None, - effort: None, - summary: None, - personality: None, - output_schema: None, - collaboration_mode: None, + let RemoteTrace { + trace_id: remote_trace_id, + parent_span_id: remote_parent_span_id, + context: remote_trace, + } = RemoteTrace::new("00000000000000000000000000000077", "0000000000000088"); + let turn_start_response: TurnStartResponse = harness + .request( + ClientRequest::TurnStart { + request_id: RequestId::Integer(3), + params: TurnStartParams { + environments: None, + thread_id, + input: vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + responsesapi_client_metadata: None, + cwd: None, + approval_policy: None, + sandbox_policy: None, + permission_profile: None, + approvals_reviewer: None, + model: None, + service_tier: None, + effort: None, + summary: None, + personality: None, + output_schema: None, + collaboration_mode: None, + }, }, - }, - Some(remote_trace), - ) - .await; - let spans = wait_for_exported_spans(harness.tracing, |spans| { - spans.iter().any(|span| { - span.span_kind == SpanKind::Server - && span_attr(span, "rpc.method") == Some("turn/start") - && span.span_context.trace_id() == remote_trace_id - }) && spans.iter().any(|span| { - span_attr(span, "codex.op") == Some("user_input") - && span.span_context.trace_id() == remote_trace_id + Some(remote_trace), + ) + .await; + let spans = wait_for_exported_spans(harness.tracing, |spans| { + spans.iter().any(|span| { + span.span_kind == SpanKind::Server + && span_attr(span, "rpc.method") == Some("turn/start") + && span.span_context.trace_id() == remote_trace_id + }) && spans.iter().any(|span| { + span_attr(span, "codex.op") == Some("user_input") + && span.span_context.trace_id() == remote_trace_id + }) }) + .await; + + let server_request_span = + find_rpc_span_with_trace(&spans, SpanKind::Server, "turn/start", remote_trace_id); + let core_turn_span = + find_span_with_trace(&spans, remote_trace_id, "codex.op=user_input", |span| { + span_attr(span, "codex.op") == Some("user_input") + }); + + 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_eq!( + span_attr(server_request_span, "turn.id"), + Some(turn_start_response.turn.id.as_str()) + ); + assert_span_descends_from(&spans, core_turn_span, server_request_span); + harness.shutdown().await; + + Ok(()) }) - .await; - - let server_request_span = - find_rpc_span_with_trace(&spans, SpanKind::Server, "turn/start", remote_trace_id); - let core_turn_span = - find_span_with_trace(&spans, remote_trace_id, "codex.op=user_input", |span| { - span_attr(span, "codex.op") == Some("user_input") - }); - - 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_eq!( - span_attr(server_request_span, "turn.id"), - Some(turn_start_response.turn.id.as_str()) - ); - assert_span_descends_from(&spans, core_turn_span, server_request_span); - harness.shutdown().await; - - Ok(()) } diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 2a19564720..16c53e460e 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -23,6 +23,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)] @@ -199,7 +200,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; } @@ -518,11 +519,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; } @@ -596,13 +593,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, diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 323aba19d7..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,10 +29,12 @@ 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, } @@ -38,6 +42,16 @@ pub(crate) struct PendingThreadResumeRequest { 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 { @@ -60,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, @@ -114,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; } @@ -122,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 22e7a80a5d..b610f099ae 100644 --- a/codex-rs/app-server/src/transport/mod.rs +++ b/codex-rs/app-server/src/transport/mod.rs @@ -7,6 +7,7 @@ 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; @@ -337,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()) } @@ -469,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; @@ -479,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!( @@ -810,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 6b0051f8db..82e02ae5de 100644 --- a/codex-rs/app-server/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server/src/transport/remote_control/tests.rs @@ -497,7 +497,8 @@ async fn remote_control_start_allows_missing_auth_when_enabled() { /*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(); @@ -1085,7 +1086,8 @@ async fn remote_control_waits_for_account_id_before_enrolling() { /*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 4eb58a87f2..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, @@ -1092,7 +1090,8 @@ mod tests { /*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(), @@ -1174,7 +1173,8 @@ mod tests { /*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 index 3075676dac..5ab1377fb4 100644 --- a/codex-rs/app-server/src/transport/unix_socket.rs +++ b/codex-rs/app-server/src/transport/unix_socket.rs @@ -11,8 +11,7 @@ use futures::StreamExt; use tokio::sync::mpsc; use tokio::task::JoinHandle; use tokio::time::Duration; -use tokio_tungstenite::WebSocketStream; -use tokio_tungstenite::tungstenite::protocol::Role; +use tokio_tungstenite::accept_async; use tokio_util::sync::CancellationToken; use tracing::error; use tracing::info; @@ -76,8 +75,13 @@ async fn run_control_socket_acceptor( let transport_event_tx = transport_event_tx.clone(); tokio::spawn(async move { - let websocket_stream = - WebSocketStream::from_raw_socket(stream, Role::Server, None).await; + 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; }); diff --git a/codex-rs/app-server/src/transport/unix_socket_tests.rs b/codex-rs/app-server/src/transport/unix_socket_tests.rs index c2f7a7d353..0b7dec0a23 100644 --- a/codex-rs/app-server/src/transport/unix_socket_tests.rs +++ b/codex-rs/app-server/src/transport/unix_socket_tests.rs @@ -16,10 +16,9 @@ use std::path::Path; use tokio::sync::mpsc; use tokio::time::Duration; use tokio::time::timeout; -use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::client_async; use tokio_tungstenite::tungstenite::Bytes; use tokio_tungstenite::tungstenite::Message as WebSocketMessage; -use tokio_tungstenite::tungstenite::protocol::Role; use tokio_util::sync::CancellationToken; #[test] @@ -54,7 +53,7 @@ fn listen_unix_socket_accepts_relative_custom_path() { } #[tokio::test] -async fn control_socket_acceptor_forwards_websocket_text_messages_and_pings() { +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) = @@ -71,7 +70,10 @@ async fn control_socket_acceptor_forwards_websocket_text_messages_and_pings() { let stream = connect_to_socket(socket_path.as_path()) .await .expect("client should connect"); - let mut websocket = WebSocketStream::from_raw_socket(stream, Role::Client, None).await; + 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 diff --git a/codex-rs/app-server/src/transport/websocket.rs b/codex-rs/app-server/src/transport/websocket.rs index 1840231c3c..7830189467 100644 --- a/codex-rs/app-server/src/transport/websocket.rs +++ b/codex-rs/app-server/src/transport/websocket.rs @@ -43,6 +43,11 @@ 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() @@ -174,7 +179,8 @@ pub(crate) async fn run_websocket_connection( StreamError: std::fmt::Display + Send + 'static, { let connection_id = next_connection_id(); - let (writer_tx, writer_rx) = mpsc::channel::(CHANNEL_CAPACITY); + 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 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 befa248e80..bcd364c742 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -106,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 @@ -130,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( @@ -147,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", 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 78a915d178..335489929d 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -151,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 { @@ -1329,6 +1391,7 @@ struct AppsServerState { expected_account_id: String, response: Arc>, directory_delay: Duration, + workspace_plugins_enabled: bool, } #[derive(Clone)] @@ -1412,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 }), @@ -1427,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 { @@ -1452,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); @@ -1462,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 c24d2e80db..83718a8dc7 100644 --- a/codex-rs/app-server/tests/suite/v2/command_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/command_exec.rs @@ -256,17 +256,18 @@ async fn command_exec_permission_profile_cwd_uses_command_cwd() -> Result<()> { timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let mut permission_profile = root_read_only_permission_profile(); - permission_profile - .file_system - .as_mut() - .expect("root read-only helper should include filesystem permissions") - .entries - .push(FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::CurrentWorkingDirectory, - }, - access: FileSystemAccessMode::Write, - }); + 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::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Write, + }); let command_request_id = mcp .send_command_exec_request(CommandExecParams { @@ -1061,11 +1062,9 @@ fn decode_delta_notification( } fn root_read_only_permission_profile() -> PermissionProfile { - PermissionProfile { - network: Some(PermissionProfileNetworkPermissions { - enabled: Some(false), - }), - file_system: Some(PermissionProfileFileSystemPermissions { + PermissionProfile::Managed { + network: PermissionProfileNetworkPermissions { enabled: false }, + file_system: PermissionProfileFileSystemPermissions::Restricted { entries: vec![FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, @@ -1073,7 +1072,7 @@ fn root_read_only_permission_profile() -> PermissionProfile { access: FileSystemAccessMode::Read, }], glob_scan_max_depth: None, - }), + }, } } 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 0c681e7fb9..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<()> { 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_upgrade.rs b/codex-rs/app-server/tests/suite/v2/marketplace_upgrade.rs index c10bb5caea..8660497da5 100644 --- a/codex-rs/app-server/tests/suite/v2/marketplace_upgrade.rs +++ b/codex-rs/app-server/tests/suite/v2/marketplace_upgrade.rs @@ -17,6 +17,9 @@ 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"; @@ -63,13 +66,14 @@ fn commit_marketplace_marker(root: &Path, marker: &str) -> Result { 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: None, + ref_name, sparse_paths: &[], } } @@ -90,12 +94,13 @@ fn record_git_marketplace( 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)), + &configured_git_marketplace_update(&source, Some(last_revision), ref_name), )?; Ok(()) } @@ -153,12 +158,14 @@ async fn marketplace_upgrade_all_configured_git_marketplaces() -> Result<()> { "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())?; @@ -205,12 +212,14 @@ async fn marketplace_upgrade_named_marketplace_only() -> Result<()> { "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())?; @@ -246,7 +255,13 @@ async fn marketplace_upgrade_returns_empty_roots_when_already_up_to_date() -> Re 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)?; + 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?; 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 4a3f231836..776424cc99 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -34,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 4ffab8f7d3..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??; } @@ -1322,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?; @@ -1351,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, 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 dfc3fea318..62ba19cc4f 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -281,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 @@ -345,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<()> { @@ -534,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 @@ -783,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 @@ -885,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 {}) @@ -957,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 @@ -1053,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 @@ -1968,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. @@ -2029,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/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index 195c4a86db..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()?; @@ -324,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 7741ced163..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(()) } @@ -294,13 +385,8 @@ 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(), @@ -405,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(), @@ -582,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 { @@ -664,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, @@ -687,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_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index c86f88825d..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(), @@ -324,6 +383,366 @@ async fn thread_resume_can_skip_turns_for_metadata_only_resume() -> Result<()> { 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; @@ -978,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(), @@ -991,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()?; @@ -1208,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() @@ -1842,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 { @@ -1867,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()?; @@ -1965,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(), @@ -2092,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(()) @@ -2115,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, }]; @@ -2151,6 +2632,7 @@ struct RestartedThreadFixture { mcp: McpProcess, thread_id: String, rollout_file_path: PathBuf, + updated_at: i64, } async fn start_materialized_thread_and_restart( @@ -2194,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); @@ -2208,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, }) } @@ -2344,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" @@ -2361,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, @@ -2382,7 +2902,6 @@ model_provider = "mock_provider" [features] personality = true -{general_analytics_toml} [model_providers.mock_provider] name = "Mock provider for test" @@ -2443,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 { @@ -2465,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(), @@ -931,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, @@ -950,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 c6eb58a432..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; @@ -39,6 +40,7 @@ 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; @@ -47,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; @@ -75,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; @@ -460,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??; @@ -535,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")?]; @@ -746,13 +677,17 @@ async fn turn_start_rejects_combined_oversized_text_input() -> Result<()> { #[tokio::test] async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() -> Result<()> { let codex_home = TempDir::new()?; - let unsupported_write_root = 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??; @@ -769,7 +704,7 @@ async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() -> ) .await??; let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; - let unsupported_write_root = AbsolutePathBuf::from_absolute_path(unsupported_write_root.path()) + let disallowed_write_root = AbsolutePathBuf::from_absolute_path(disallowed_write_root.path()) .expect("tempdir path should be absolute"); let turn_req = mcp @@ -779,17 +714,17 @@ async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() -> text: "Hello".to_string(), text_elements: Vec::new(), }], - permission_profile: Some(PermissionProfile { - network: None, - file_system: Some(PermissionProfileFileSystemPermissions { + permission_profile: Some(PermissionProfile::Managed { + network: PermissionProfileNetworkPermissions { enabled: false }, + file_system: PermissionProfileFileSystemPermissions::Restricted { entries: vec![FileSystemSandboxEntry { path: FileSystemPath::Path { - path: unsupported_write_root, + path: disallowed_write_root, }, access: FileSystemAccessMode::Write, }], glob_scan_max_depth: None, - }), + }, }), ..Default::default() }) @@ -803,9 +738,9 @@ async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() -> assert_eq!(err.error.code, INVALID_REQUEST_ERROR_CODE); assert!(err.error.message.contains("invalid turn context override")); assert!( - err.error - .message - .contains("filesystem writes outside the workspace root") + 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), @@ -820,6 +755,69 @@ async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() -> 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. @@ -1829,7 +1827,6 @@ 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, @@ -1926,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/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 fe19c3015e..0000000000 --- a/codex-rs/chatgpt/src/chatgpt_token.rs +++ /dev/null @@ -1,36 +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, - /*chatgpt_base_url*/ None, - ); - 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 4c6f05a681..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,22 +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, - Some(config.chatgpt_base_url.clone()), - ); + 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()); @@ -66,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, @@ -95,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::( @@ -128,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 d318297f8f..5f4f3aee10 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -24,7 +24,6 @@ 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 } @@ -39,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 e173f65734..c85da0f5f2 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -171,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, @@ -189,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()); @@ -221,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() { @@ -288,7 +292,10 @@ async fn run_command_under_windows_session( use codex_windows_sandbox::spawn_windows_sandbox_session_elevated; use codex_windows_sandbox::spawn_windows_sandbox_session_legacy; - let policy_str = match serde_json::to_string(config.permissions.sandbox_policy.get()) { + 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}"); @@ -715,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 42241aa933..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) => { diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 3dee811fac..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,6 +24,8 @@ 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; @@ -41,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; @@ -59,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; @@ -161,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), @@ -216,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, @@ -257,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. @@ -349,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, @@ -812,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, @@ -930,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, @@ -945,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; } @@ -1065,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(), @@ -1105,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(), @@ -1265,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, @@ -1344,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 @@ -1511,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, ) @@ -1799,12 +1839,13 @@ mod tests { } #[test] - fn responses_subcommand_is_hidden_from_help_but_parses() { - let help = MultitoolCli::command().render_help().to_string(); - assert!(!help.contains("responses")); - - let cli = MultitoolCli::try_parse_from(["codex", "responses"]).expect("parse"); - assert!(matches!(cli.subcommand, Some(Subcommand::Responses(_)))); + fn responses_subcommand_is_not_registered() { + let command = MultitoolCli::command(); + assert!( + command + .get_subcommands() + .all(|subcommand| subcommand.get_name() != "responses") + ); } fn help_from_args(args: &[&str]) -> 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 6974198ef7..0000000000 --- a/codex-rs/cli/src/responses_cmd.rs +++ /dev/null @@ -1,246 +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::ModelVerifications(verifications) => { - json!({ "type": "response.model_verifications", "verifications": verifications }) - } - 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/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index ca9ec56fe9..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; @@ -176,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> { @@ -335,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); @@ -558,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; @@ -728,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, @@ -739,7 +738,8 @@ pub fn cloud_requirements_loader_for_storage( enable_codex_api_key_env, credentials_store_mode, Some(chatgpt_base_url.clone()), - ); + ) + .await; cloud_requirements_loader(auth_manager, chatgpt_base_url, codex_home) } @@ -854,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", @@ -862,15 +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, - /*chatgpt_base_url*/ None, - )) + 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>, @@ -887,12 +890,15 @@ mod tests { ), ) .expect("write auth"); - Arc::new(AuthManager::new( - tmp.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*chatgpt_base_url*/ None, - )) + 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( @@ -976,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>, @@ -996,18 +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, - /*chatgpt_base_url*/ None, - )), + 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 { @@ -1119,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, @@ -1135,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, @@ -1148,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()), }), @@ -1162,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()), }), @@ -1194,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()), }), @@ -1226,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()), }), @@ -1315,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), }, )]), @@ -1330,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, @@ -1357,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, @@ -1406,12 +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, - /*chatgpt_base_url*/ None, - )); + 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(), @@ -1480,12 +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, - /*chatgpt_base_url*/ None, - )); + 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(), @@ -1560,7 +1576,8 @@ enabled = false Some("account-12345"), "stale-access-token", "test-refresh-token", - ); + ) + .await; write_auth_json( auth._home.path(), chatgpt_auth_json( @@ -1612,12 +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, - /*chatgpt_base_url*/ None, - )); + 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: @@ -1654,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, @@ -1679,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, @@ -1701,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()), }), @@ -1712,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, @@ -1748,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()), }), @@ -1791,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()), }), @@ -1808,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, @@ -1844,7 +1866,8 @@ enabled = false "business", Some("user-12345"), Some("account-12345"), - ), + ) + .await, Arc::new(StaticFetcher { contents: Some("allowed_approval_policies = [\"never\"]".to_string()), }), @@ -1861,7 +1884,8 @@ enabled = false "business", Some("user-99999"), Some("account-12345"), - ), + ) + .await, fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, @@ -1893,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()), }), @@ -1918,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, @@ -1976,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, @@ -2008,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()), }), @@ -2071,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, @@ -2089,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, @@ -2122,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, 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 525ea3b594..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,39 +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(config.chatgpt_base_url), - )) + 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; @@ -85,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/codex-api/src/auth.rs b/codex-rs/codex-api/src/auth.rs index e1130c7707..41394a2258 100644 --- a/codex-rs/codex-api/src/auth.rs +++ b/codex-rs/codex-api/src/auth.rs @@ -34,6 +34,13 @@ pub trait AuthProvider: Send + Sync { /// 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 diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 6f118d1030..4b150b55f1 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -81,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 { diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 7b4a4ceab0..fb1742463f 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -123,6 +123,8 @@ struct ResponseCompleted { id: String, #[serde(default)] usage: Option, + #[serde(default)] + end_turn: Option, } #[derive(Debug, Deserialize)] @@ -382,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) => { @@ -704,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:?}"), } @@ -843,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:?}"), } @@ -1148,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" ); } @@ -1184,7 +1192,8 @@ mod tests { &events[2], ResponseEvent::Completed { response_id, - token_usage: None + token_usage: None, + end_turn: None, } if response_id == "resp-1" ); } @@ -1218,7 +1227,8 @@ mod tests { &events[1], ResponseEvent::Completed { response_id, - token_usage: None + token_usage: None, + end_turn: None, } if response_id == "resp-1" ); } diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 46f5627592..218a99f9b2 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -423,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-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 1e1e0fd3f6..0000000000 --- a/codex-rs/codex-mcp/src/mcp_connection_manager.rs +++ /dev/null @@ -1,1864 +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::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_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 { - #[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, -} - -/// 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 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, - } - } - - 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::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, - } => { - if remote_environment && !runtime_environment.environment().is_remote() { - return Err(StartupOutcomeError::from(anyhow!( - "remote MCP server `{server_name}` requires a remote environment" - ))); - } - 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, - runtime_environment.environment().get_http_client(), - ) - .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 9df08b115d..3c7e5a8296 100644 --- a/codex-rs/config/Cargo.toml +++ b/codex-rs/config/Cargo.toml @@ -14,14 +14,18 @@ workspace = true [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } +base64 = { workspace = true } codex-app-server-protocol = { workspace = true } +codex-exec-server = { workspace = true } codex-execpolicy = { workspace = true } codex-features = { 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 } @@ -44,8 +48,16 @@ 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 } diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 56ff26f907..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; @@ -84,7 +84,7 @@ 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>, @@ -110,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( @@ -842,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 @@ -967,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, @@ -984,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 { @@ -1014,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 }) => { @@ -1145,7 +1128,7 @@ impl TryFrom for ConfigRequirements { Ok(ConfigRequirements { approval_policy, approvals_reviewer, - sandbox_policy, + permission_profile, web_search_mode, feature_requirements, managed_hooks, @@ -1159,6 +1142,29 @@ impl TryFrom for ConfigRequirements { } } +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::*; @@ -1168,6 +1174,7 @@ mod tests { 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; @@ -1183,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, @@ -1724,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(), @@ -1803,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!( @@ -1869,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() ); @@ -1952,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(), @@ -1981,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(), @@ -2065,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(), @@ -2110,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(), @@ -2149,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(), diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 8d6321e412..2821de5a8b 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -47,9 +47,9 @@ 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::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; @@ -213,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. @@ -317,6 +319,9 @@ pub struct ConfigToml { /// 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. @@ -413,6 +418,20 @@ 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. @@ -566,6 +585,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. /// @@ -626,7 +648,7 @@ impl ConfigToml { profile_sandbox_mode: Option, windows_sandbox_level: WindowsSandboxLevel, active_project: Option<&ProjectConfig>, - sandbox_policy_constraint: Option<&crate::Constrained>, + permission_profile_constraint: Option<&crate::Constrained>, ) -> SandboxPolicy { let sandbox_mode_was_explicit = sandbox_mode_override.is_some() || profile_sandbox_mode.is_some() @@ -665,7 +687,6 @@ impl ConfigToml { 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, @@ -687,14 +708,16 @@ impl ConfigToml { 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(&PermissionProfile::from_legacy_sandbox_policy( + &sandbox_policy, + )) { tracing::warn!( error = %err, "default sandbox policy is disallowed by requirements; falling back to required default" ); - sandbox_policy = constraint.get().clone(); + sandbox_policy = SandboxPolicy::new_read_only_policy(); downgrade_workspace_write_if_unsupported(&mut sandbox_policy); } sandbox_policy diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index e3d95acb86..d628fbf04f 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -7,6 +7,7 @@ mod fingerprint; mod hook_config; mod host_name; mod key_aliases; +pub mod loader; mod marketplace_edit; mod mcp_edit; mod mcp_types; @@ -17,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; @@ -53,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; 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 96% rename from codex-rs/core/src/config_loader/layer_io.rs rename to codex-rs/config/src/loader/layer_io.rs index 6bd9a9130f..773a71f3bf 100644 --- a/codex-rs/core/src/config_loader/layer_io.rs +++ b/codex-rs/config/src/loader/layer_io.rs @@ -1,10 +1,10 @@ -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 crate::diagnostics::config_error_from_toml; +use crate::diagnostics::io_error_from_config_error; +use crate::state::LoaderOverrides; use codex_exec_server::ExecutorFileSystem; use codex_utils_absolute_path::AbsolutePathBuf; use std::io; 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 91% rename from codex-rs/core/src/config_loader/mod.rs rename to codex-rs/config/src/loader/mod.rs index 4681aa0753..6375490354 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -2,17 +2,29 @@ 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_git_utils::resolve_root_git_project_for_trust; use codex_protocol::config_types::ApprovalsReviewer; @@ -29,71 +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::HookEventsToml; -pub use codex_config::HookHandlerConfig; -pub use codex_config::LoaderOverrides; -pub use codex_config::ManagedHooksRequirementsToml; -pub use codex_config::MatcherGroup; -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 @@ -136,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 = @@ -148,7 +102,6 @@ pub async fn load_config_layers_state( &mut config_requirements_toml, RequirementSource::CloudRequirements, requirements, - host_name, ); } @@ -158,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?; @@ -210,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( @@ -428,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) @@ -465,7 +410,6 @@ async fn load_requirements_toml( file: requirements_toml_file.clone(), }, requirements_config, - host_name, ); } Err(e) => { @@ -494,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| { @@ -580,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 @@ -617,7 +578,6 @@ async fn load_requirements_from_legacy_scheme( config_requirements_toml, source, ConfigRequirementsToml::from(legacy_config), - host_name, ); } @@ -628,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); } @@ -844,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 { 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 642770ff7e..f6f63191b5 100644 --- a/codex-rs/config/src/profile_toml.rs +++ b/codex-rs/config/src/profile_toml.rs @@ -41,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/types.rs b/codex-rs/config/src/types.rs index 7413686a77..114ded97e9 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)] @@ -584,6 +589,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 { @@ -697,21 +709,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)] @@ -732,37 +729,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. @@ -794,19 +760,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 036b160365..8a0e4f7720 100644 --- a/codex-rs/core-plugins/Cargo.toml +++ b/codex-rs/core-plugins/Cargo.toml @@ -19,6 +19,7 @@ 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 } diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 32d0bec7af..589467199e 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -42,6 +42,7 @@ const DEFAULT_SKILLS_DIR_NAME: &str = "skills"; const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json"; const DEFAULT_APP_CONFIG_FILE: &str = ".app.json"; 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-plugins/src/marketplace_add.rs b/codex-rs/core-plugins/src/marketplace_add.rs index aeea5872d9..57e587e480 100644 --- a/codex-rs/core-plugins/src/marketplace_add.rs +++ b/codex-rs/core-plugins/src/marketplace_add.rs @@ -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-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index add99f2be8..e453c52f97 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -107,6 +107,20 @@ pub enum RemotePluginCatalogError { 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 install 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, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)] @@ -258,6 +272,12 @@ struct RemotePluginInstalledResponse { 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>, @@ -418,6 +438,41 @@ pub async fn fetch_remote_plugin_detail( }) } +pub async fn install_remote_plugin( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + marketplace_name: &str, + plugin_id: &str, +) -> 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(()) +} + fn build_remote_plugin_summary( plugin: &RemotePluginDirectoryItem, installed_plugin: Option<&RemotePluginInstalledItem>, @@ -608,7 +663,7 @@ fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePlu let Some(auth) = auth else { return Err(RemotePluginCatalogError::AuthRequired); }; - if !auth.is_chatgpt_auth() { + if !auth.uses_codex_backend() { return Err(RemotePluginCatalogError::UnsupportedAuthMode); } Ok(auth) @@ -618,16 +673,9 @@ fn authenticated_request( request: RequestBuilder, auth: &CodexAuth, ) -> Result { - let token = auth - .get_token() - .map_err(RemotePluginCatalogError::AuthToken)?; - let mut request = request + Ok(request .timeout(REMOTE_PLUGIN_CATALOG_TIMEOUT) - .bearer_auth(token); - if let Some(account_id) = auth.get_account_id() { - request = request.header("chatgpt-account-id", account_id); - } - Ok(request) + .headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers())) } async fn send_and_decode Deserialize<'de>>( diff --git a/codex-rs/core-plugins/src/remote_legacy.rs b/codex-rs/core-plugins/src/remote_legacy.rs index 7b57ab1320..dcf9f79eb8 100644 --- a/codex-rs/core-plugins/src/remote_legacy.rs +++ b/codex-rs/core-plugins/src/remote_legacy.rs @@ -123,23 +123,17 @@ pub async fn fetch_remote_plugin_status( let Some(auth) = auth else { return Err(RemotePluginFetchError::AuthRequired); }; - if !auth.is_chatgpt_auth() { + 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 token = auth - .get_token() - .map_err(RemotePluginFetchError::AuthToken)?; - let mut request = client + let 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); - } + .headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers()); let response = request .send() @@ -176,14 +170,9 @@ pub async fn fetch_remote_featured_plugin_ids( )]) .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); - } + 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 @@ -223,11 +212,13 @@ pub async fn uninstall_remote_plugin( Ok(()) } -fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginMutationError> { +fn ensure_codex_backend_auth( + auth: Option<&CodexAuth>, +) -> Result<&CodexAuth, RemotePluginMutationError> { let Some(auth) = auth else { return Err(RemotePluginMutationError::AuthRequired); }; - if !auth.is_chatgpt_auth() { + if !auth.uses_codex_backend() { return Err(RemotePluginMutationError::UnsupportedAuthMode); } Ok(auth) @@ -243,19 +234,13 @@ async fn post_remote_plugin_mutation( plugin_id: &str, action: &str, ) -> Result { - let auth = ensure_chatgpt_auth(auth)?; + let auth = ensure_codex_backend_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 + let 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); - } + .headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers()); let response = request .send() 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 add2fcaf55..002ee1b3a4 100644 --- a/codex-rs/core-skills/src/render.rs +++ b/codex-rs/core-skills/src/render.rs @@ -1,3 +1,9 @@ +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; @@ -5,6 +11,7 @@ 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; @@ -14,6 +21,66 @@ 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 { @@ -66,6 +133,7 @@ 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 warning_message: Option, @@ -89,10 +157,11 @@ 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() { record_skill_render_side_effects( side_effects, @@ -104,7 +173,42 @@ pub fn build_available_skills( return None; } - let (skill_lines, report) = render_skill_lines(skills, budget); + 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" @@ -134,29 +238,39 @@ pub fn build_available_skills( } else { None }; - record_skill_render_side_effects( - side_effects, - report.total_count, - report.included_count, - report.omitted_count, - report.truncated_description_chars, - ); - if report.omitted_count > 0 || report.truncated_description_chars > 0 { - tracing::info!( - budget_limit = budget.limit(), - total_skills = report.total_count, - included_skills = report.included_count, - omitted_skills = report.omitted_count, - truncated_description_chars_per_skill = report.average_truncated_description_chars(), - truncated_skill_descriptions = report.truncated_description_count, - "truncated skill metadata to fit skills context budget" - ); - } - Some(AvailableSkills { + let available = AvailableSkills { + skill_root_lines: path_aliases.skill_root_lines, skill_lines, report, 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 { @@ -204,16 +318,11 @@ fn record_skill_render_side_effects( } } -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 skill_lines = ordered_skills - .into_iter() - .map(SkillLine::new) - .collect::>(); - let full_cost = skill_lines.iter().fold(0usize, |used, line| { used.saturating_add(line.full_cost(budget)) }); @@ -226,7 +335,7 @@ fn render_skill_lines( return ( included, skill_render_report( - /*total_count*/ skills.len(), + total_count, /*included_count*/ skill_lines.len(), /*omitted_count*/ 0, /*truncated_description_chars*/ 0, @@ -254,7 +363,7 @@ fn render_skill_lines( return ( included, skill_render_report( - /*total_count*/ skills.len(), + total_count, /*included_count*/ skill_lines.len(), /*omitted_count*/ 0, truncated_description_chars, @@ -263,7 +372,7 @@ fn render_skill_lines( ); } - render_minimum_skill_lines_until_budget(budget, skill_lines, skills.len()) + render_minimum_skill_lines_until_budget(budget, skill_lines, total_count) } fn render_minimum_skill_lines_until_budget( @@ -366,10 +475,17 @@ fn sum_description_truncation(rendered: &[RenderedSkillLine]) -> (usize, usize) 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: skill.path_to_skills_md.to_string_lossy().replace('\\', "/"), + path, } } @@ -455,6 +571,12 @@ 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<'_>], @@ -510,6 +632,253 @@ fn render_lines_with_description_budget( .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| { @@ -533,6 +902,9 @@ fn prompt_scope_rank(scope: SkillScope) -> u8 { #[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; @@ -564,6 +936,48 @@ mod tests { 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!( @@ -597,12 +1011,8 @@ mod tests { + SkillLine::new(&beta).minimum_cost(SkillMetadataBudget::Characters(usize::MAX)); let budget = SkillMetadataBudget::Characters(minimum_cost + 6); - let rendered = build_available_skills( - &[beta.clone(), alpha.clone()], - budget, - SkillRenderSideEffects::None, - ) - .expect("skills should render"); + 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); @@ -626,7 +1036,7 @@ mod tests { + SkillLine::new(&beta).minimum_cost(SkillMetadataBudget::Characters(usize::MAX)); let budget = SkillMetadataBudget::Characters(minimum_cost + 6); - let rendered = build_available_skills(&[alpha, beta], budget, SkillRenderSideEffects::None) + let rendered = build_available_skills_from_metadata(&[alpha, beta], budget) .expect("skills should render"); assert_eq!(rendered.report.included_count, 2); @@ -646,7 +1056,7 @@ mod tests { + SkillLine::new(&beta).minimum_cost(SkillMetadataBudget::Characters(usize::MAX)); let budget = SkillMetadataBudget::Characters(minimum_cost + 6); - let rendered = build_available_skills(&[alpha, beta], budget, SkillRenderSideEffects::None) + let rendered = build_available_skills_from_metadata(&[alpha, beta], budget) .expect("skills should render"); assert_eq!(rendered.report.included_count, 2); @@ -671,12 +1081,8 @@ mod tests { + SkillLine::new(&long).minimum_cost(SkillMetadataBudget::Characters(usize::MAX)); let budget = SkillMetadataBudget::Characters(minimum_cost + 11); - let rendered = build_available_skills( - &[short.clone(), long.clone()], - budget, - SkillRenderSideEffects::None, - ) - .expect("skills should render"); + 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); @@ -702,12 +1108,8 @@ mod tests { .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); @@ -735,9 +1137,8 @@ mod tests { .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); @@ -752,4 +1153,335 @@ mod tests { 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 42deea9684..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 } @@ -120,9 +122,6 @@ uuid = { workspace = true, features = ["serde", "v4", "v5"] } which = { workspace = true } whoami = { 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] openssl-sys = { workspace = true, features = ["vendored"] } @@ -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 0673619971..e4d156c540 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", @@ -417,7 +421,7 @@ "fast_mode": { "type": "boolean" }, - "general_analytics": { + "goals": { "type": "boolean" }, "guardian_approval": { @@ -519,6 +523,9 @@ "telepathy": { "type": "boolean" }, + "terminal_resize_reflow": { + "type": "boolean" + }, "tool_call_mcp_elicitation": { "type": "boolean" }, @@ -579,16 +586,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" }, @@ -1310,6 +1307,11 @@ "hide_spawn_agent_metadata": { "type": "boolean" }, + "max_concurrent_threads_per_session": { + "format": "uint", + "minimum": 1.0, + "type": "integer" + }, "usage_hint_enabled": { "type": "boolean" }, @@ -1709,7 +1711,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, @@ -1718,9 +1720,6 @@ }, "type": "array" }, - "bearer_token": { - "type": "string" - }, "bearer_token_env_var": { "type": "string" }, @@ -2092,6 +2091,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": { @@ -2222,6 +2257,13 @@ }, "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`.", @@ -2488,6 +2530,14 @@ "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" @@ -2578,7 +2628,7 @@ "fast_mode": { "type": "boolean" }, - "general_analytics": { + "goals": { "type": "boolean" }, "guardian_approval": { @@ -2680,6 +2730,9 @@ "telepathy": { "type": "boolean" }, + "terminal_resize_reflow": { + "type": "boolean" + }, "tool_call_mcp_elicitation": { "type": "boolean" }, @@ -2804,21 +2857,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 c54d0663ba..daf1acf8f3 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)] @@ -246,6 +248,7 @@ impl AgentControl { /*metrics_service_name*/ None, inherited_shell_snapshot, inherited_exec_policy, + options.environments.clone(), ) .await? } @@ -259,7 +262,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) => { @@ -405,6 +407,7 @@ impl AgentControl { /*persist_extended_history*/ false, inherited_shell_snapshot, inherited_exec_policy, + options.environments.clone(), ) .await } diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index e83ee6d4b5..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, } } @@ -519,7 +518,6 @@ async fn append_message_records_assistant_message() { content: vec![ContentItem::InputText { text: message.to_string(), }], - end_turn: None, phase: None, }, ) @@ -657,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 @@ -677,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)), @@ -751,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 @@ -860,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 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..08b7465178 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 { 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 fd6d7faa01..c49e28f20a 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -1127,7 +1127,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 @@ -1655,6 +1655,7 @@ where Ok(ResponseEvent::Completed { response_id, token_usage, + end_turn, }) => { if let Some(usage) = &token_usage { session_telemetry.sse_event_completed( @@ -1680,6 +1681,7 @@ where .send(Ok(ResponseEvent::Completed { response_id, token_usage, + end_turn, })) .await .is_err() diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 1a30d3263f..f3df3cd4c6 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -47,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; @@ -64,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, @@ -73,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, @@ -92,24 +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)), - inherited_rollout_trace: codex_rollout_trace::RolloutTraceRecorder::disabled(), + 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 @@ -159,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, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 5f34283562..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) = diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index cda2d22fb0..7454b98651 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -1,6 +1,7 @@ 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; @@ -103,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; @@ -230,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 4ae9e9fcdc..ed7a95b96e 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; @@ -295,7 +294,6 @@ async fn run_compact_task_inner_impl( } pub(crate) struct CompactionAnalyticsAttempt { - enabled: bool, thread_id: String, turn_id: String, trigger: CompactionTrigger, @@ -316,10 +314,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, @@ -338,9 +334,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 @@ -509,7 +502,6 @@ fn build_compacted_history_with_limit( content: vec![ContentItem::InputText { text: message.clone(), }], - end_turn: None, phase: None, }); } @@ -524,7 +516,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, }); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 962b3e6721..0623ceb3b6 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -118,8 +118,7 @@ async fn run_remote_compact_task_inner_impl( 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_trace.compaction_trace_context( - sess.conversation_id, + 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(), 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 93% rename from codex-rs/core/src/config_loader/tests.rs rename to codex-rs/core/src/config/config_loader_tests.rs index 7f61952690..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)]), }) ); @@ -788,7 +789,6 @@ allowed_approval_policies = ["on-request"] })) }), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; @@ -848,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?; @@ -878,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"] "#, ) @@ -898,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(), @@ -939,7 +939,6 @@ deny_read = ["./sensitive", "../shared/secret.txt"] LOCAL_FS.as_ref(), &mut config_requirements_toml, &requirements_file, - /*host_name*/ None, ) .await?; @@ -994,7 +993,6 @@ deny_read = ["./sensitive/**/*.txt"] LOCAL_FS.as_ref(), &mut config_requirements_toml, &requirements_file, - /*host_name*/ None, ) .await?; @@ -1063,7 +1061,6 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> LoaderOverrides::default(), cloud_requirements, &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; @@ -1126,7 +1123,6 @@ async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result LoaderOverrides::default(), cloud_requirements, &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; @@ -1155,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"] "#, )?; @@ -1168,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() ); @@ -1211,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"); @@ -1260,7 +1256,6 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> { LoaderOverrides::default(), CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; @@ -1268,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(); @@ -1407,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()), @@ -1449,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); @@ -1508,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(), @@ -1581,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!( @@ -1622,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!( @@ -1690,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!( @@ -1845,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(), @@ -1915,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(), @@ -1977,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?; @@ -2022,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?; @@ -2030,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(); @@ -2052,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 d46d1a316d..995e4299c6 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1,13 +1,13 @@ 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; @@ -20,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; @@ -55,16 +56,16 @@ 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::FileSystemPermissions; -use codex_protocol::models::NetworkPermissions; +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; @@ -521,10 +522,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( @@ -758,7 +777,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 { @@ -787,20 +806,16 @@ 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(()) @@ -810,19 +825,35 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: async fn permission_profile_override_populates_runtime_permissions() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; - let permission_profile = PermissionProfile { - network: Some(NetworkPermissions { - enabled: Some(true), - }), - file_system: Some(FileSystemPermissions { - entries: vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Write, - }], - glob_scan_max_depth: None, - }), + 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( @@ -838,8 +869,118 @@ async fn permission_profile_override_populates_runtime_permissions() -> std::io: assert_eq!(config.permissions.permission_profile(), permission_profile); assert_eq!( - config.permissions.sandbox_policy.get(), - &SandboxPolicy::DangerFullAccess + &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::CurrentWorkingDirectory, + }, + 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(()) } @@ -848,20 +989,7 @@ async fn permission_profile_override_populates_runtime_permissions() -> std::io: 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 { - network: Some(NetworkPermissions { - enabled: Some(true), - }), - file_system: Some(FileSystemPermissions { - entries: vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Write, - }], - glob_scan_max_depth: None, - }), - }; + let permission_profile = PermissionProfile::Disabled; let config = Config::load_from_base_config_with_overrides( ConfigToml { @@ -955,7 +1083,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) ); @@ -965,7 +1093,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 { @@ -977,7 +1105,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!( @@ -1034,13 +1162,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 { @@ -1050,7 +1181,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), )]), }), @@ -1066,14 +1197,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(()) } @@ -1163,7 +1305,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( @@ -1175,12 +1317,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, } ); @@ -1213,7 +1351,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())), @@ -1240,16 +1378,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, } ); @@ -1275,7 +1409,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!( @@ -1372,16 +1506,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(()) } @@ -1428,10 +1556,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#" @@ -1449,7 +1636,7 @@ network_access = false # This should be ignored. /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, /*active_project*/ None, - /*sandbox_policy_constraint*/ None, + /*permission_profile_constraint*/ None, ) .await; assert_eq!(resolution, SandboxPolicy::DangerFullAccess); @@ -1470,7 +1657,7 @@ network_access = true # This should be ignored. /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, /*active_project*/ None, - /*sandbox_policy_constraint*/ None, + /*permission_profile_constraint*/ None, ) .await; assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); @@ -1502,7 +1689,7 @@ trust_level = "trusted" /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, /*active_project*/ None, - /*sandbox_policy_constraint*/ None, + /*permission_profile_constraint*/ None, ) .await; if cfg!(target_os = "windows") { @@ -1512,7 +1699,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, @@ -1543,7 +1729,7 @@ exclude_slash_tmp = true /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, /*active_project*/ None, - /*sandbox_policy_constraint*/ None, + /*permission_profile_constraint*/ None, ) .await; if cfg!(target_os = "windows") { @@ -1553,7 +1739,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, @@ -1608,22 +1793,22 @@ exclude_slash_tmp = true ) .await?; - let sandbox_policy = config.permissions.sandbox_policy.get(); + let sandbox_policy = &config.legacy_sandbox_policy(); assert_eq!( - config.permissions.file_system_sandbox_policy, - FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, cwd.path()), + config.permissions.file_system_sandbox_policy(), + FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(sandbox_policy, cwd.path()), "case `{name}` should preserve filesystem semantics from legacy config" ); assert_eq!( - config.permissions.network_sandbox_policy, + config.permissions.network_sandbox_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_sandbox_policy() + .to_legacy_sandbox_policy(config.permissions.network_sandbox_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" @@ -1791,12 +1976,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 @@ -1854,7 +2039,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:?}"), } @@ -1865,7 +2050,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 @@ -2068,24 +2253,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); } @@ -2105,7 +2291,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(()) @@ -2183,7 +2369,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 )); @@ -2217,12 +2403,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 { .. } )); } @@ -2346,7 +2532,6 @@ async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> { overrides, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; let cfg = @@ -2482,7 +2667,6 @@ async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> { overrides, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; @@ -3791,7 +3975,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(), ), @@ -3872,7 +4056,7 @@ async fn requirements_guardian_policy_beats_auto_review() -> std::io::Result<()> let config_layer_stack = ConfigLayerStack::new( Vec::new(), Default::default(), - crate::config_loader::ConfigRequirementsToml { + codex_config::ConfigRequirementsToml { guardian_policy_config: Some("Use the managed guardian policy.".to_string()), ..Default::default() }, @@ -3936,7 +4120,7 @@ async fn load_config_ignores_empty_requirements_guardian_policy_config() -> std: 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() }, @@ -3969,6 +4153,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 { @@ -4045,6 +4230,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()?; @@ -4827,6 +5069,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()?; @@ -4835,6 +5100,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 { @@ -4877,6 +5143,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 { @@ -4913,6 +5180,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 { @@ -4949,6 +5217,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 { @@ -5172,11 +5441,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(), @@ -5205,6 +5470,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(), @@ -5216,8 +5482,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, @@ -5236,8 +5500,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, @@ -5268,6 +5532,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(), @@ -5369,11 +5634,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(), @@ -5402,6 +5663,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(), @@ -5413,8 +5675,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, @@ -5433,8 +5693,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, @@ -5465,6 +5725,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(), @@ -5520,11 +5781,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(), @@ -5553,6 +5810,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(), @@ -5564,8 +5822,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, @@ -5584,8 +5840,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, @@ -5616,6 +5872,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(), @@ -5656,11 +5913,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(), @@ -5689,6 +5942,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(), @@ -5700,8 +5954,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, @@ -5720,8 +5972,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, @@ -5752,6 +6004,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(), @@ -5772,14 +6025,12 @@ 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, @@ -5790,7 +6041,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| { @@ -5805,15 +6056,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( @@ -6065,7 +6316,7 @@ trust_level = "untrusted" /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, Some(&active_project), - /*sandbox_policy_constraint*/ None, + /*permission_profile_constraint*/ None, ) .await; @@ -6086,8 +6337,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(); @@ -6103,14 +6354,14 @@ 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, }) } @@ -6126,7 +6377,7 @@ async fn derive_sandbox_policy_falls_back_to_constraint_value_for_implicit_defau ) .await; - assert_eq!(resolution, SandboxPolicy::DangerFullAccess); + assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); Ok(()) } @@ -6148,18 +6399,29 @@ 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( @@ -6399,7 +6661,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" @@ -6407,7 +6669,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" @@ -6425,17 +6687,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(()) @@ -6450,10 +6710,10 @@ 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, @@ -6476,12 +6736,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<()> { @@ -6496,9 +6843,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() })) @@ -6510,7 +6857,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, ); @@ -6537,7 +6884,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() })) @@ -6566,7 +6913,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() })) @@ -6587,8 +6934,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), @@ -6621,8 +6968,8 @@ async fn feature_requirements_auto_review_disables_guardian_approval() -> std::i 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([("auto_review".to_string(), false)]), }), ..Default::default() @@ -6643,8 +6990,8 @@ async fn browser_feature_requirements_are_valid() -> std::io::Result<()> { 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([ ("in_app_browser".to_string(), false), ("browser_use".to_string(), false), @@ -6678,8 +7025,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), @@ -6825,7 +7172,7 @@ 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 { + Ok(Some(codex_config::ConfigRequirementsToml { allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), ..Default::default() })) @@ -6851,7 +7198,7 @@ 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 { + Ok(Some(codex_config::ConfigRequirementsToml { allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), ..Default::default() })) @@ -6888,7 +7235,7 @@ 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 { + Ok(Some(codex_config::ConfigRequirementsToml { allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), ..Default::default() })) @@ -6914,7 +7261,7 @@ 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::AutoReview, @@ -7001,6 +7348,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 @@ -7014,6 +7362,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(), @@ -7032,11 +7382,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 @@ -7049,6 +7401,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(), @@ -7059,6 +7412,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()?; @@ -7066,8 +7493,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), @@ -7102,8 +7529,8 @@ async fn feature_requirements_warn_on_collab_legacy_alias() -> std::io::Result<( 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() @@ -7132,8 +7559,8 @@ async fn feature_requirements_warn_and_ignore_unknown_feature() -> std::io::Resu 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([("made_up_feature".to_string(), true)]), }), ..Default::default() diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index cb6f788399..47f2e4dd9f 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,12 +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; @@ -113,6 +117,7 @@ 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; @@ -126,6 +131,7 @@ pub use codex_git_utils::GhostSnapshotConfig; /// 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"; @@ -189,14 +195,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. @@ -221,11 +222,93 @@ impl Permissions { /// Effective runtime permissions after config requirements and runtime /// readable-root additions have been applied. pub fn permission_profile(&self) -> PermissionProfile { - PermissionProfile::from_runtime_permissions( - &self.file_system_sandbox_policy, - self.network_sandbox_policy, + 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. @@ -378,6 +461,9 @@ pub struct Config { /// 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. @@ -430,6 +516,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, @@ -476,12 +565,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, @@ -540,13 +623,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, @@ -623,6 +705,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, @@ -631,6 +714,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, @@ -638,6 +723,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() @@ -656,7 +757,7 @@ impl AuthManagerConfig for Config { } } -#[derive(Clone)] +#[derive(Clone, Default)] pub struct ConfigBuilder { codex_home: Option, cli_overrides: Option>, @@ -665,22 +766,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 { @@ -722,11 +807,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, @@ -736,7 +816,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)?, @@ -761,7 +840,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(); @@ -773,10 +851,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), @@ -802,6 +883,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, @@ -876,8 +969,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( @@ -941,7 +1034,6 @@ pub async fn load_config_as_toml_with_cli_and_loader_overrides( loader_overrides, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; @@ -1123,7 +1215,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(); @@ -1292,6 +1383,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, @@ -1344,7 +1450,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() { @@ -1389,8 +1495,6 @@ pub struct ConfigOverrides { 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, @@ -1479,6 +1583,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)) @@ -1494,12 +1602,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, @@ -1509,11 +1632,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 [ @@ -1578,7 +1701,7 @@ 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: _, @@ -1609,8 +1732,6 @@ impl Config { 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, @@ -1751,10 +1872,9 @@ impl Config { && has_permission_profiles); let ( configured_network_proxy_config, - sandbox_policy, + permission_profile, file_system_sandbox_policy, - network_sandbox_policy, - ) = if let Some(permission_profile) = permission_profile { + ) = 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 = @@ -1773,35 +1893,35 @@ impl Config { })?; let profile = resolve_permission_profile(permissions, default_permissions)?; - // PermissionProfile only carries the network enabled bit today. Keep the - // configured proxy/allowlist policy so active profiles can round-trip without - // broadening network behavior. + // 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 mut sandbox_policy = permission_profile - .to_legacy_sandbox_policy(resolved_cwd.as_path()) - .map_err(|err| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("invalid permission_profile override: {err}"), - ) - })?; + 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_with_enforcement( + permission_profile.enforcement(), + &file_system_sandbox_policy, + network_sandbox_policy, + ); } ( configured_network_proxy_config, - sandbox_policy, + permission_profile, file_system_sandbox_policy, - network_sandbox_policy, ) } else if profiles_are_active { let permissions = cfg.permissions.as_ref().ok_or_else(|| { @@ -1826,22 +1946,31 @@ 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(); @@ -1851,7 +1980,7 @@ impl Config { 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 { @@ -1861,16 +1990,21 @@ impl Config { } } } - let file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy( + let file_system_sandbox_policy = + FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( &sandbox_policy, resolved_cwd.as_path(), ); 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, + ); ( 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() @@ -1917,6 +2051,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) @@ -1952,24 +2087,35 @@ impl Config { let history = cfg.history.unwrap_or_default(); + if multi_agent_v2.max_concurrent_threads_per_session == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "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); - if features.enabled(Feature::MultiAgentV2) && 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", - )); - } - 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) { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "agents.max_threads must be at least 1", - )); - } + 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() @@ -2000,6 +2146,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) @@ -2138,20 +2289,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)); @@ -2177,8 +2314,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, @@ -2192,17 +2328,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)?; @@ -2220,9 +2361,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( @@ -2240,10 +2381,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() { @@ -2265,17 +2407,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, .. @@ -2288,12 +2426,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, @@ -2306,9 +2447,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, @@ -2364,6 +2503,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, @@ -2374,8 +2514,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), @@ -2422,8 +2560,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, @@ -2474,6 +2615,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); @@ -2558,8 +2700,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() @@ -2630,3 +2772,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/permissions_instructions.rs b/codex-rs/core/src/context/permissions_instructions.rs index 6ba4e7c15d..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 = ""; diff --git a/codex-rs/core/src/context/permissions_instructions_tests.rs b/codex-rs/core/src/context/permissions_instructions_tests.rs index 866c68b4a7..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(); 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..7e66a5b703 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -519,6 +519,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>> = @@ -621,6 +625,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)) }) } diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index bd8e77fd24..ad67deb544 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -26,6 +26,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 +42,6 @@ fn assistant_msg(text: &str) -> ResponseItem { content: vec![ContentItem::OutputText { text: text.to_string(), }], - end_turn: None, phase: None, } } @@ -60,7 +60,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 +79,6 @@ fn user_msg(text: &str) -> ResponseItem { content: vec![ContentItem::OutputText { text: text.to_string(), }], - end_turn: None, phase: None, } } @@ -92,7 +90,6 @@ fn user_input_text_msg(text: &str) -> ResponseItem { content: vec![ContentItem::InputText { text: text.to_string(), }], - end_turn: None, phase: None, } } @@ -104,7 +101,6 @@ fn developer_msg(text: &str) -> ResponseItem { content: vec![ContentItem::InputText { text: text.to_string(), }], - end_turn: None, phase: None, } } @@ -119,7 +115,6 @@ fn developer_msg_with_fragments(texts: &[&str]) -> ResponseItem { text: (*text).to_string(), }) .collect(), - end_turn: None, phase: None, } } @@ -200,7 +195,6 @@ fn filters_non_api_messages() { content: vec![ContentItem::OutputText { text: "ignored".to_string(), }], - end_turn: None, phase: None, }; let reasoning = reasoning_msg("thinking..."); @@ -231,7 +225,6 @@ fn filters_non_api_messages() { content: vec![ContentItem::OutputText { text: "hi".to_string() }], - end_turn: None, phase: None, }, ResponseItem::Message { @@ -240,7 +233,6 @@ fn filters_non_api_messages() { content: vec![ContentItem::OutputText { text: "hello".to_string() }], - end_turn: None, phase: None, } ] @@ -390,7 +382,6 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { text: "caption".to_string(), }, ], - end_turn: None, phase: None, }, ResponseItem::FunctionCall { @@ -453,7 +444,6 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { text: "caption".to_string(), }, ], - end_turn: None, phase: None, }, ResponseItem::FunctionCall { @@ -512,7 +502,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); @@ -540,7 +529,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, }, ]); @@ -560,7 +548,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, } ] @@ -576,7 +563,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 { @@ -596,7 +582,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 { @@ -758,7 +743,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()); @@ -1690,7 +1674,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 { @@ -1699,7 +1682,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, }; @@ -1773,7 +1755,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 { @@ -1805,7 +1786,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, }; @@ -1846,7 +1826,6 @@ fn mixed_case_data_url_markers_are_adjusted() { image_url, detail: Some(DEFAULT_IMAGE_DETAIL), }], - end_turn: None, phase: None, }; @@ -1879,7 +1858,6 @@ fn multiple_inline_images_apply_multiple_fixed_costs() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ], - end_turn: None, phase: None, }; @@ -1923,6 +1901,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. @@ -1962,7 +1972,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 862b2698d1..1bc2cb0895 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -56,8 +56,8 @@ fn build_permissions_update_item( } 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..fb90ef322f 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,10 @@ fn read_only_file_system_sandbox_policy() -> FileSystemSandboxPolicy { }]) } +fn workspace_write_file_system_sandbox_policy() -> FileSystemSandboxPolicy { + FileSystemSandboxPolicy::from_legacy_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()) +} + fn unrestricted_file_system_sandbox_policy() -> FileSystemSandboxPolicy { FileSystemSandboxPolicy::unrestricted() } @@ -116,6 +122,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 +964,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 +983,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 +993,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::CurrentWorkingDirectory, + }, + 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 +1129,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 +1169,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 +1196,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 +1224,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 +1256,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 +1288,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 +1323,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 +1363,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 +1589,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 +1850,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 +1877,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 +1900,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 +1997,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..4e8ba10c20 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, @@ -572,7 +571,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 +617,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, @@ -658,6 +655,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 +669,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 +692,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 +707,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, @@ -747,6 +744,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 +761,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, @@ -811,7 +808,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, @@ -861,7 +857,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, @@ -1026,11 +1021,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