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/.github/actions/linux-code-sign/action.yml b/.github/actions/linux-code-sign/action.yml index 12e521187f..f8efb822f8 100644 --- a/.github/actions/linux-code-sign/action.yml +++ b/.github/actions/linux-code-sign/action.yml @@ -7,6 +7,9 @@ inputs: artifacts-dir: description: Absolute path to the directory containing built binaries to sign. required: true + binaries: + description: Space-delimited binary basenames to sign. + default: "codex codex-responses-api-proxy" runs: using: composite @@ -18,6 +21,7 @@ runs: shell: bash env: ARTIFACTS_DIR: ${{ inputs.artifacts-dir }} + BINARIES: ${{ inputs.binaries }} COSIGN_EXPERIMENTAL: "1" COSIGN_YES: "true" COSIGN_OIDC_CLIENT_ID: "sigstore" @@ -31,7 +35,7 @@ runs: exit 1 fi - for binary in codex codex-responses-api-proxy; do + for binary in ${BINARIES}; do artifact="${dest}/${binary}" if [[ ! -f "$artifact" ]]; then echo "Binary $artifact not found" diff --git a/.github/actions/macos-code-sign/action.yml b/.github/actions/macos-code-sign/action.yml index 200b23901f..0e19fa11d0 100644 --- a/.github/actions/macos-code-sign/action.yml +++ b/.github/actions/macos-code-sign/action.yml @@ -4,6 +4,9 @@ inputs: target: description: Rust compilation target triple (e.g. aarch64-apple-darwin). required: true + binaries: + description: Space-delimited binary basenames to sign and notarize. + default: "codex codex-responses-api-proxy" sign-binaries: description: Whether to sign and notarize the macOS binaries. required: false @@ -119,6 +122,7 @@ runs: shell: bash env: TARGET: ${{ inputs.target }} + BINARIES: ${{ inputs.binaries }} run: | set -euo pipefail @@ -134,7 +138,7 @@ runs: entitlements_path="$GITHUB_ACTION_PATH/codex.entitlements.plist" - for binary in codex codex-responses-api-proxy; do + for binary in ${BINARIES}; do path="codex-rs/target/${TARGET}/release/${binary}" codesign --force --options runtime --timestamp --entitlements "$entitlements_path" --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$path" done @@ -144,6 +148,7 @@ runs: shell: bash env: TARGET: ${{ inputs.target }} + BINARIES: ${{ inputs.binaries }} APPLE_NOTARIZATION_KEY_P8: ${{ inputs.apple-notarization-key-p8 }} APPLE_NOTARIZATION_KEY_ID: ${{ inputs.apple-notarization-key-id }} APPLE_NOTARIZATION_ISSUER_ID: ${{ inputs.apple-notarization-issuer-id }} @@ -182,8 +187,9 @@ runs: notarize_submission "$binary" "$archive_path" "$notary_key_path" } - notarize_binary "codex" - notarize_binary "codex-responses-api-proxy" + for binary in ${BINARIES}; do + notarize_binary "${binary}" + done - name: Sign and notarize macOS dmg if: ${{ inputs.sign-dmg == 'true' }} diff --git a/.github/actions/prepare-bazel-ci/action.yml b/.github/actions/prepare-bazel-ci/action.yml index 78f5aeb9a3..48c6ba74b4 100644 --- a/.github/actions/prepare-bazel-ci/action.yml +++ b/.github/actions/prepare-bazel-ci/action.yml @@ -8,7 +8,7 @@ inputs: description: Logical namespace used to keep concurrent Bazel jobs from reserving the same repository cache key. required: true install-test-prereqs: - description: Install Node.js and DotSlash for Bazel-backed test jobs. + description: Install DotSlash for Bazel-backed test jobs. required: false default: "false" outputs: diff --git a/.github/actions/setup-bazel-ci/action.yml b/.github/actions/setup-bazel-ci/action.yml index 008e87c496..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 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/run-bazel-ci.sh b/.github/scripts/run-bazel-ci.sh index cf2d4ce340..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 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:]' , +) -> 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/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index ed17314630..352acbe1fc 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -161,7 +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).into() + CorePermissionProfile::Disabled.into() } fn sample_app_server_client_metadata() -> CodexAppServerClientMetadata { @@ -315,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/facts.rs b/codex-rs/analytics/src/facts.rs index 1d371acb1c..8ebff278c4 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -13,12 +13,12 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; +use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookSource; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; @@ -62,7 +62,8 @@ pub struct TurnResolvedConfigFact { pub session_source: SessionSource, pub model: String, pub model_provider: String, - pub sandbox_policy: SandboxPolicy, + pub permission_profile: PermissionProfile, + pub permission_profile_cwd: PathBuf, pub reasoning_effort: Option, pub reasoning_summary: Option, pub service_tier: Option, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index a6ce3fc831..0445975dd6 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -61,7 +61,7 @@ use codex_login::default_client::originator; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::TokenUsage; @@ -884,7 +884,8 @@ fn codex_turn_event_params( session_source: _session_source, model, model_provider, - sandbox_policy, + permission_profile, + permission_profile_cwd, reasoning_effort, reasoning_summary, service_tier, @@ -909,7 +910,10 @@ fn codex_turn_event_params( parent_thread_id: thread_metadata.parent_thread_id.clone(), model: Some(model), model_provider, - sandbox_policy: Some(sandbox_policy_mode(&sandbox_policy)), + sandbox_policy: Some(sandbox_policy_mode( + &permission_profile, + permission_profile_cwd.as_path(), + )), reasoning_effort: reasoning_effort.map(|value| value.to_string()), reasoning_summary: reasoning_summary_mode(reasoning_summary), service_tier: service_tier @@ -954,12 +958,27 @@ fn codex_turn_event_params( } } -fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { - match sandbox_policy { - SandboxPolicy::DangerFullAccess => "full_access", - SandboxPolicy::ReadOnly { .. } => "read_only", - SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", - SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", +fn sandbox_policy_mode(permission_profile: &PermissionProfile, cwd: &Path) -> &'static str { + match permission_profile { + PermissionProfile::Disabled => "full_access", + PermissionProfile::External { .. } => "external_sandbox", + PermissionProfile::Managed { .. } => { + let file_system_policy = permission_profile.file_system_sandbox_policy(); + if file_system_policy.has_full_disk_write_access() { + if permission_profile.network_sandbox_policy().is_enabled() { + "full_access" + } else { + "external_sandbox" + } + } else if file_system_policy + .get_writable_roots_with_cwd(cwd) + .is_empty() + { + "read_only" + } else { + "workspace_write" + } + } } } @@ -1050,3 +1069,25 @@ pub(crate) fn normalize_path_for_skill_id( _ => resolved_path.to_string_lossy().replace('\\', "/"), } } + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::models::SandboxEnforcement; + use codex_protocol::permissions::FileSystemSandboxPolicy; + use codex_protocol::permissions::NetworkSandboxPolicy; + + #[test] + fn managed_full_disk_with_restricted_network_reports_external_sandbox() { + let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( + SandboxEnforcement::Managed, + &FileSystemSandboxPolicy::unrestricted(), + NetworkSandboxPolicy::Restricted, + ); + + assert_eq!( + sandbox_policy_mode(&permission_profile, Path::new("/")), + "external_sandbox" + ); + } +} diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 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 d7631e1572..cf665f4a5c 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2126,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", @@ -2339,12 +2292,6 @@ }, "type": "array" }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, "id": { "type": [ "string", @@ -3056,16 +3003,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -3122,16 +3059,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" @@ -3458,6 +3385,15 @@ ], "type": "object" }, + "ThreadGoalStatus": { + "enum": [ + "active", + "paused", + "budgetLimited", + "complete" + ], + "type": "string" + }, "ThreadInjectItemsParams": { "properties": { "items": { @@ -6188,4 +6124,4 @@ } ], "title": "ClientRequest" -} \ No newline at end of file +} 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/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index cdc9da679e..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 @@ -3806,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": { @@ -12228,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", @@ -12746,12 +12739,6 @@ }, "type": "array" }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, "id": { "type": [ "string", @@ -13483,16 +13470,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/v2/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -13549,16 +13526,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/v2/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" @@ -14678,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" }, @@ -17694,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 04c91b5a28..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 @@ -8942,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", @@ -9460,12 +9413,6 @@ }, "type": "array" }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, "id": { "type": [ "string", @@ -10197,16 +10144,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -10263,16 +10200,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" @@ -10511,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": { @@ -12564,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" }, @@ -15579,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 6ba2fc0db4..b85a0e7911 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json @@ -374,53 +374,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" - } - ] - }, "SandboxPolicy": { "oneOf": [ { @@ -441,16 +394,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -507,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/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/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 281650bb3a..a2f2490a0b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1028,53 +1028,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" - } - ] - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -1107,16 +1060,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -1173,16 +1116,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/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 40ff83aeb3..872a3eb324 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -756,12 +756,6 @@ }, "type": "array" }, - "end_turn": { - "type": [ - "boolean", - "null" - ] - }, "id": { "type": [ "string", @@ -1458,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 573cbe92d0..516627576e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1028,53 +1028,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" - } - ] - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -1107,16 +1060,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -1173,16 +1116,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/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 1de06c6039..f773c0be69 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1028,53 +1028,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" - } - ] - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -1107,16 +1060,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -1173,16 +1116,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/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index 245c57886e..559698100f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -462,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": [ @@ -561,16 +514,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -627,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/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/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/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 1c5be70da5..016d6e16b8 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, @@ -1027,6 +1042,10 @@ server_notification_definitions! { ThreadClosed => "thread/closed" (v2::ThreadClosedNotification), SkillsChanged => "skills/changed" (v2::SkillsChangedNotification), ThreadNameUpdated => "thread/name/updated" (v2::ThreadNameUpdatedNotification), + #[experimental("thread/goal/updated")] + ThreadGoalUpdated => "thread/goal/updated" (v2::ThreadGoalUpdatedNotification), + #[experimental("thread/goal/cleared")] + ThreadGoalCleared => "thread/goal/cleared" (v2::ThreadGoalClearedNotification), ThreadTokenUsageUpdated => "thread/tokenUsage/updated" (v2::ThreadTokenUsageUpdatedNotification), TurnStarted => "turn/started" (v2::TurnStartedNotification), HookStarted => "hook/started" (v2::HookStartedNotification), @@ -2046,6 +2065,76 @@ mod tests { let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request); assert_eq!(reason, Some("thread/realtime/start")); } + + #[test] + fn thread_goal_methods_are_marked_experimental() { + let set_request = ClientRequest::ThreadGoalSet { + request_id: RequestId::Integer(1), + params: v2::ThreadGoalSetParams { + thread_id: "thr_123".to_string(), + objective: Some("ship goal mode".to_string()), + status: Some(v2::ThreadGoalStatus::Active), + token_budget: Some(Some(10_000)), + }, + }; + let get_request = ClientRequest::ThreadGoalGet { + request_id: RequestId::Integer(2), + params: v2::ThreadGoalGetParams { + thread_id: "thr_123".to_string(), + }, + }; + let clear_request = ClientRequest::ThreadGoalClear { + request_id: RequestId::Integer(3), + params: v2::ThreadGoalClearParams { + thread_id: "thr_123".to_string(), + }, + }; + + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&set_request), + Some("thread/goal/set") + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&get_request), + Some("thread/goal/get") + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&clear_request), + Some("thread/goal/clear") + ); + } + + #[test] + fn thread_goal_notifications_are_marked_experimental() { + let goal = v2::ThreadGoal { + thread_id: "thr_123".to_string(), + objective: "ship goal mode".to_string(), + status: v2::ThreadGoalStatus::Active, + token_budget: Some(10_000), + tokens_used: 123, + time_used_seconds: 45, + created_at: 1_700_000_000, + updated_at: 1_700_000_123, + }; + let updated = ServerNotification::ThreadGoalUpdated(v2::ThreadGoalUpdatedNotification { + thread_id: "thr_123".to_string(), + turn_id: None, + goal, + }); + let cleared = ServerNotification::ThreadGoalCleared(v2::ThreadGoalClearedNotification { + thread_id: "thr_123".to_string(), + }); + + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&updated), + Some("thread/goal/updated") + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&cleared), + Some("thread/goal/cleared") + ); + } + #[test] fn thread_realtime_started_notification_is_marked_experimental() { let notification = diff --git a/codex-rs/app-server-protocol/src/protocol/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 5936b3e142..b7dccc8613 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -83,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; @@ -97,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; @@ -808,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/")] @@ -1719,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/")] @@ -1775,8 +1724,6 @@ pub enum SandboxPolicy { #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] ReadOnly { - #[serde(default)] - access: ReadOnlyAccess, #[serde(default)] network_access: bool, }, @@ -1792,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)] @@ -1802,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 { @@ -1825,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, @@ -1846,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 { @@ -1863,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, @@ -3731,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/")] @@ -6254,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/")] @@ -7578,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; @@ -8784,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, }; @@ -8798,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, } ); @@ -9425,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, @@ -9443,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, @@ -9458,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/README.md b/codex-rs/app-server/README.md index 840b6cb70f..35df7016c4 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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. @@ -1233,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 8af3f87615..a1eba990c6 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; @@ -1954,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, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 981b52a598..814fee9d8c 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; @@ -150,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 +231,9 @@ use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCre 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; @@ -235,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; @@ -353,6 +365,8 @@ 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; @@ -479,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(); @@ -661,9 +678,11 @@ 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(configured_local_thread_store(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), } } @@ -950,6 +969,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; @@ -1323,7 +1354,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 { @@ -1474,7 +1505,7 @@ impl CodexMessageProcessor { .await; if success { - auth_manager.reload(); + auth_manager.reload().await; config_manager.replace_cloud_requirements_loader( auth_manager.clone(), chatgpt_base_url, @@ -1582,7 +1613,7 @@ impl CodexMessageProcessor { .await; if success { - auth_manager.reload(); + auth_manager.reload().await; config_manager.replace_cloud_requirements_loader( auth_manager.clone(), chatgpt_base_url, @@ -1718,7 +1749,7 @@ impl CodexMessageProcessor { self.outgoing.send_error(request_id, error).await; return; } - self.auth_manager.reload(); + self.auth_manager.reload().await; self.config_manager.replace_cloud_requirements_loader( self.auth_manager.clone(), self.config.chatgpt_base_url.clone(), @@ -2178,7 +2209,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, @@ -2242,44 +2273,30 @@ 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; - } - }; + 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, + ); match self .config .permissions - .sandbox_policy - .can_set(&sandbox_policy) + .permission_profile + .can_set(&effective_permission_profile) { - 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, - ) - } + Ok(()) => effective_permission_profile, Err(err) => { let error = JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, @@ -2291,13 +2308,39 @@ impl CodexMessageProcessor { } } } else if let Some(policy) = sandbox_policy.map(|policy| policy.to_core()) { - match self.config.permissions.sandbox_policy.can_set(&policy) { + match self + .config + .permissions + .can_set_legacy_sandbox_policy(&policy, &sandbox_cwd) + { Ok(()) => { 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); - (policy, file_system_sandbox_policy, network_sandbox_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, + ); + if let Err(err) = self + .config + .permissions + .permission_profile + .can_set(&permission_profile) + { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid sandbox policy: {err}"), + data: None, + }; + self.outgoing.send_error(request, error).await; + return; + } + permission_profile } Err(err) => { let error = JSONRPCErrorError { @@ -2310,11 +2353,7 @@ impl CodexMessageProcessor { } } } 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(); @@ -2333,9 +2372,7 @@ impl CodexMessageProcessor { match 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, @@ -2622,16 +2659,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 @@ -4690,6 +4725,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 { @@ -4855,6 +4898,17 @@ 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(), @@ -4862,6 +4916,8 @@ impl CodexMessageProcessor { config_snapshot, instruction_sources, thread_summary, + emit_thread_goal_update, + thread_goal_state_db, include_turns: !params.exclude_turns, }), ); @@ -4874,6 +4930,7 @@ impl CodexMessageProcessor { data: None, }; self.outgoing.send_error(request_id, err).await; + return true; } return true; } @@ -6267,6 +6324,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, @@ -6289,41 +6355,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, @@ -6402,34 +6433,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 { @@ -6437,9 +6467,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) { @@ -6533,6 +6561,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, @@ -6542,15 +6580,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, }; @@ -6604,7 +6634,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); } @@ -6614,25 +6644,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" + ))); } }; @@ -6642,26 +6660,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)); } } @@ -6691,27 +6697,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, @@ -6736,17 +6741,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 @@ -6755,13 +6756,7 @@ 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()) @@ -6840,9 +6835,7 @@ impl CodexMessageProcessor { errors, }); } - self.outgoing - .send_response(request_id, SkillsListResponse { data }) - .await; + Ok(SkillsListResponse { data }) } async fn marketplace_remove( &self, @@ -6855,27 +6848,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( @@ -6883,53 +6865,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) { @@ -6941,28 +6908,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( @@ -6970,6 +6926,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, @@ -6984,43 +6948,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( @@ -8653,7 +8598,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(), @@ -8795,6 +8742,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, @@ -8910,6 +8880,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, @@ -8959,9 +8935,61 @@ 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> { @@ -9161,6 +9189,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( @@ -9454,6 +9483,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()) @@ -10005,18 +10055,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> { @@ -10301,11 +10353,11 @@ 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; @@ -10313,6 +10365,7 @@ mod tests { 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; @@ -10517,16 +10570,10 @@ mod tests { #[test] fn thread_response_permission_profile_preserves_enforcement() { - let full_access_profile = - codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy( - &SandboxPolicy::DangerFullAccess, - ); - let external_profile = - codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy( - &SandboxPolicy::ExternalSandbox { - network_access: codex_protocol::protocol::NetworkAccess::Restricted, - }, - ); + let full_access_profile = codex_protocol::models::PermissionProfile::Disabled; + let external_profile = codex_protocol::models::PermissionProfile::External { + network: codex_protocol::permissions::NetworkSandboxPolicy::Restricted, + }; assert_eq!( thread_response_permission_profile(external_profile.clone()), @@ -10541,17 +10588,24 @@ mod tests { #[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, - ); - let workspace_write_profile = - codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy( - &SandboxPolicy::new_workspace_write_policy(), - ); - let read_only_profile = - codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), + 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( @@ -10568,6 +10622,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), @@ -10760,10 +10821,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, - ), + permission_profile: codex_protocol::models::PermissionProfile::Disabled, cwd, ephemeral: false, reasoning_effort: None, @@ -10829,6 +10887,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([( @@ -10859,6 +10921,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([( @@ -10887,6 +10950,7 @@ mod tests { ); assert_eq!(typesafe_overrides.model, None); + assert_eq!(typesafe_overrides.model_provider, None); assert_eq!( request_overrides, Some(HashMap::from([( @@ -10938,6 +11002,7 @@ mod tests { ); assert_eq!(typesafe_overrides.model, None); + assert_eq!(typesafe_overrides.model_provider, None); assert_eq!( request_overrides, Some(HashMap::from([( @@ -10962,6 +11027,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(()) } 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 8f0f4dea9a..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,6 @@ use super::*; +use crate::error_code::internal_error; +use crate::error_code::invalid_request; use codex_app_server_protocol::PluginInstallPolicy; impl CodexMessageProcessor { @@ -7,46 +9,33 @@ 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 { - 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()); } plugins_manager.maybe_start_non_curated_plugin_cache_refresh(&roots); @@ -100,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}" + ))); } }; @@ -174,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( @@ -191,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, @@ -201,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) => { @@ -232,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, @@ -287,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 { @@ -310,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() @@ -357,9 +295,7 @@ impl CodexMessageProcessor { } }; - self.outgoing - .send_response(request_id, PluginReadResponse { plugin }) - .await; + Ok(PluginReadResponse { plugin }) } pub(super) async fn plugin_install( @@ -367,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, @@ -375,44 +319,27 @@ impl CodexMessageProcessor { let marketplace_path = match (marketplace_path, remote_marketplace_name) { (Some(marketplace_path), None) => marketplace_path, (None, Some(remote_marketplace_name)) => { - self.remote_plugin_install(request_id, remote_marketplace_name, plugin_name) + 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 = match self.load_latest_config(config_cwd.clone()).await { - Ok(config) => config, - Err(err) => { - self.outgoing.send_error(request_id, err).await; - return; - } - }; + 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 { - self.send_invalid_request_error( - request_id, - "Codex plugins are disabled for this workspace".to_string(), - ) - .await; - return; + return Err(invalid_request( + "Codex plugins are disabled for this workspace", + )); } let plugins_manager = self.thread_manager.plugins_manager(); @@ -421,197 +348,103 @@ 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:?}" - ); - 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; - - self.outgoing - .send_response( - request_id, - PluginInstallResponse { - auth_policy: result.auth_policy.into(), - apps_needing_auth, - }, - ) - .await; - } - Err(err) => { - if err.is_invalid_request() { - self.send_invalid_request_error(request_id, err.to_string()) - .await; - return; - } - - match err { - CorePluginInstallError::Marketplace(err) => { - self.send_marketplace_error(request_id, err, "install plugin") - .await; - } - CorePluginInstallError::Config(err) => { - self.send_internal_error( - request_id, - format!("failed to persist installed plugin config: {err}"), - ) - .await; - } - CorePluginInstallError::Remote(err) => { - self.send_internal_error( - request_id, - format!("failed to enable remote plugin: {err}"), - ) - .await; - } - CorePluginInstallError::Join(err) => { - self.send_internal_error( - request_id, - format!("failed to install plugin: {err}"), - ) - .await; - } - CorePluginInstallError::Store(err) => { - self.send_internal_error( - request_id, - format!("failed to install plugin: {err}"), - ) - .await; - } - } - } - } - } - - async fn remote_plugin_install( - &self, - request_id: ConnectionRequestId, - remote_marketplace_name: String, - plugin_name: String, - ) { - let config = match self.load_latest_config(/*fallback_cwd*/ None).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) => { - self.outgoing.send_error(request_id, err).await; - return; + 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) { - self.outgoing - .send_error( - request_id, - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!( - "remote plugin install is not enabled for marketplace {remote_marketplace_name}" - ), - data: None, - }, - ) - .await; - return; + 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 == '~') { - 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 auth = self.auth_manager.auth().await; let remote_plugin_service_config = RemotePluginServiceConfig { chatgpt_base_url: config.chatgpt_base_url.clone(), }; - 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 before install", - ), - ) - .await; - return; - } - }; + .map_err(|err| { + remote_plugin_catalog_error_to_jsonrpc(err, "read remote plugin details before install") + })?; if remote_detail.summary.install_policy == PluginInstallPolicy::NotAvailable { - self.send_invalid_request_error( - request_id, - format!("remote plugin {plugin_name} is not available for install"), - ) - .await; - return; + return Err(invalid_request(format!( + "remote plugin {plugin_name} is not available for install" + ))); } - if let Err(err) = codex_core_plugins::remote::install_remote_plugin( + codex_core_plugins::remote::install_remote_plugin( &remote_plugin_service_config, auth.as_ref(), &remote_marketplace_name, &plugin_name, ) .await - { - self.outgoing - .send_error( - request_id, - remote_plugin_catalog_error_to_jsonrpc(err, "install remote plugin"), - ) - .await; - return; - } + .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "install remote plugin"))?; self.clear_plugin_related_caches(); @@ -629,15 +462,10 @@ impl CodexMessageProcessor { ) .await; - self.outgoing - .send_response( - request_id, - PluginInstallResponse { - auth_policy: remote_detail.summary.auth_policy, - apps_needing_auth, - }, - ) - .await; + Ok(PluginInstallResponse { + auth_policy: remote_detail.summary.auth_policy, + apps_needing_auth, + }) } async fn plugin_apps_needing_auth_for_install( @@ -709,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}")), } } } 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 b72c84e906..18077a9942 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -34,9 +34,9 @@ use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::sync::watch; -use crate::error_code::INTERNAL_ERROR_CODE; -use crate::error_code::INVALID_PARAMS_ERROR_CODE; -use crate::error_code::INVALID_REQUEST_ERROR_CODE; +use crate::error_code::internal_error; +use crate::error_code::invalid_params; +use crate::error_code::invalid_request; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::ConnectionRequestId; use crate::outgoing_message::OutgoingMessageSender; @@ -158,7 +158,7 @@ impl CommandExecManager { } = params; if process_id.is_none() && (tty || stream_stdin || stream_stdout_stderr) { return Err(invalid_request( - "command/exec tty or streaming requires a client-supplied processId".to_string(), + "command/exec tty or streaming requires a client-supplied processId", )); } let process_id = process_id.map_or_else( @@ -178,12 +178,12 @@ impl CommandExecManager { if matches!(exec_request.sandbox, SandboxType::WindowsRestrictedToken) { if tty || stream_stdin || stream_stdout_stderr { return Err(invalid_request( - "streaming command/exec is not supported with windows sandbox".to_string(), + "streaming command/exec is not supported with windows sandbox", )); } if output_bytes_cap != Some(DEFAULT_OUTPUT_BYTES_CAP) { return Err(invalid_request( - "custom outputBytesCap is not supported with windows sandbox".to_string(), + "custom outputBytesCap is not supported with windows sandbox", )); } if let InternalProcessId::Client(_) = &process_id { @@ -249,7 +249,7 @@ impl CommandExecManager { let sessions = Arc::clone(&self.sessions); let (program, args) = command .split_first() - .ok_or_else(|| invalid_request("command must not be empty".to_string()))?; + .ok_or_else(|| invalid_request("command must not be empty"))?; { let mut sessions = self.sessions.lock().await; if sessions.contains_key(&process_key) { @@ -312,7 +312,7 @@ impl CommandExecManager { ) -> Result { if params.delta_base64.is_none() && !params.close_stdin { return Err(invalid_params( - "command/exec/write requires deltaBase64 or closeStdin".to_string(), + "command/exec/write requires deltaBase64 or closeStdin", )); } @@ -421,7 +421,7 @@ impl CommandExecManager { }; let CommandExecSession::Active { control_tx } = session else { return Err(invalid_request( - "command/exec/write, command/exec/terminate, and command/exec/resize are not supported for windows sandbox processes".to_string(), + "command/exec/write, command/exec/terminate, and command/exec/resize are not supported for windows sandbox processes", )); }; let (response_tx, response_rx) = oneshot::channel(); @@ -635,7 +635,7 @@ async fn handle_process_write( ) -> Result<(), JSONRPCErrorError> { if !stream_stdin { return Err(invalid_request( - "stdin streaming is not enabled for this command/exec".to_string(), + "stdin streaming is not enabled for this command/exec", )); } if !delta.is_empty() { @@ -643,7 +643,7 @@ async fn handle_process_write( .writer_sender() .send(delta) .await - .map_err(|_| invalid_request("stdin is already closed".to_string()))?; + .map_err(|_| invalid_request("stdin is already closed"))?; } if close_stdin { session.close_stdin(); @@ -665,7 +665,7 @@ pub(crate) fn terminal_size_from_protocol( ) -> Result { if size.rows == 0 || size.cols == 0 { return Err(invalid_params( - "command/exec size rows and cols must be greater than 0".to_string(), + "command/exec size rows and cols must be greater than 0", )); } Ok(TerminalSize { @@ -681,39 +681,13 @@ fn command_no_longer_running_error(process_id: &InternalProcessId) -> JSONRPCErr )) } -fn invalid_request(message: String) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message, - data: None, - } -} - -fn invalid_params(message: String) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INVALID_PARAMS_ERROR_CODE, - message, - data: None, - } -} - -fn internal_error(message: String) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message, - data: None, - } -} - #[cfg(test)] mod tests { use std::collections::HashMap; + use crate::error_code::INVALID_REQUEST_ERROR_CODE; use codex_protocol::config_types::WindowsSandboxLevel; - use codex_protocol::permissions::FileSystemSandboxPolicy; - use codex_protocol::permissions::NetworkSandboxPolicy; - use codex_protocol::protocol::ReadOnlyAccess; - use codex_protocol::protocol::SandboxPolicy; + use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; #[cfg(not(target_os = "windows"))] @@ -730,13 +704,10 @@ mod tests { use crate::outgoing_message::OutgoingMessage; fn windows_sandbox_exec_request() -> ExecRequest { - let sandbox_policy = SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, - network_access: false, - }; + let cwd = AbsolutePathBuf::current_dir().expect("current dir"); ExecRequest::new( vec!["cmd".to_string()], - AbsolutePathBuf::current_dir().expect("current dir"), + cwd, HashMap::new(), /*network*/ None, ExecExpiration::DefaultTimeout, @@ -744,9 +715,7 @@ mod tests { SandboxType::WindowsRestrictedToken, WindowsSandboxLevel::Disabled, /*windows_sandbox_private_desktop*/ false, - sandbox_policy.clone(), - FileSystemSandboxPolicy::from(&sandbox_policy), - NetworkSandboxPolicy::from(&sandbox_policy), + PermissionProfile::read_only(), /*arg0*/ None, ) } @@ -836,10 +805,7 @@ mod tests { connection_id: ConnectionId(8), request_id: codex_app_server_protocol::RequestId::Integer(100), }; - let sandbox_policy = SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, - network_access: false, - }; + let cwd = AbsolutePathBuf::current_dir().expect("current dir"); manager .start(StartCommandExecParams { @@ -848,7 +814,7 @@ mod tests { process_id: Some("proc-100".to_string()), exec_request: ExecRequest::new( vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()], - AbsolutePathBuf::current_dir().expect("current dir"), + cwd.clone(), HashMap::new(), /*network*/ None, ExecExpiration::Cancellation(CancellationToken::new()), @@ -856,9 +822,7 @@ mod tests { SandboxType::None, WindowsSandboxLevel::Disabled, /*windows_sandbox_private_desktop*/ false, - sandbox_policy.clone(), - FileSystemSandboxPolicy::from(&sandbox_policy), - NetworkSandboxPolicy::from(&sandbox_policy), + PermissionProfile::read_only(), /*arg0*/ None, ), started_network_proxy: None, diff --git a/codex-rs/app-server/src/config_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 dbbc32f1c1..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,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 async_trait::async_trait; use base64::Engine; use base64::engine::general_purpose::STANDARD; @@ -302,16 +302,13 @@ fn protection_class_from_store( } fn map_device_key_error(error: DeviceKeyError) -> JSONRPCErrorError { - let code = match error { + match &error { DeviceKeyError::DegradedProtectionNotAllowed { .. } | DeviceKeyError::HardwareBackedKeysUnavailable | DeviceKeyError::KeyNotFound - | DeviceKeyError::InvalidPayload(_) => INVALID_REQUEST_ERROR_CODE, - DeviceKeyError::Platform(_) | DeviceKeyError::Crypto(_) => INTERNAL_ERROR_CODE, - }; - JSONRPCErrorError { - code, - message: error.to_string(), - data: None, + | DeviceKeyError::InvalidPayload(_) => invalid_request(error.to_string()), + DeviceKeyError::Platform(_) | DeviceKeyError::Crypto(_) => { + internal_error(error.to_string()) + } } } diff --git a/codex-rs/app-server/src/error_code.rs b/codex-rs/app-server/src/error_code.rs index 924a7086ae..0054d2988f 100644 --- a/codex-rs/app-server/src/error_code.rs +++ b/codex-rs/app-server/src/error_code.rs @@ -1,5 +1,27 @@ +use codex_app_server_protocol::JSONRPCErrorError; + pub(crate) const INVALID_REQUEST_ERROR_CODE: i64 = -32600; pub const INVALID_PARAMS_ERROR_CODE: i64 = -32602; pub(crate) const INTERNAL_ERROR_CODE: i64 = -32603; pub(crate) const OVERLOADED_ERROR_CODE: i64 = -32001; pub const INPUT_TOO_LARGE_ERROR_CODE: &str = "input_too_large"; + +pub(crate) fn invalid_request(message: impl Into) -> JSONRPCErrorError { + error(INVALID_REQUEST_ERROR_CODE, message) +} + +pub(crate) fn invalid_params(message: impl Into) -> JSONRPCErrorError { + error(INVALID_PARAMS_ERROR_CODE, message) +} + +pub(crate) fn internal_error(message: impl Into) -> JSONRPCErrorError { + error(INTERNAL_ERROR_CODE, message) +} + +fn error(code: i64, message: impl Into) -> JSONRPCErrorError { + JSONRPCErrorError { + code, + message: message.into(), + data: None, + } +} diff --git a/codex-rs/app-server/src/external_agent_config_api.rs b/codex-rs/app-server/src/external_agent_config_api.rs index 0741ad5bd8..34ad572caf 100644 --- a/codex-rs/app-server/src/external_agent_config_api.rs +++ b/codex-rs/app-server/src/external_agent_config_api.rs @@ -3,7 +3,7 @@ use crate::config::external_agent_config::ExternalAgentConfigMigrationItem as Co use crate::config::external_agent_config::ExternalAgentConfigMigrationItemType as CoreMigrationItemType; use crate::config::external_agent_config::ExternalAgentConfigService; use crate::config::external_agent_config::PendingPluginImport; -use crate::error_code::INTERNAL_ERROR_CODE; +use crate::error_code::internal_error; use codex_app_server_protocol::ExternalAgentConfigDetectParams; use codex_app_server_protocol::ExternalAgentConfigDetectResponse; use codex_app_server_protocol::ExternalAgentConfigImportParams; @@ -12,7 +12,6 @@ use codex_app_server_protocol::ExternalAgentConfigMigrationItemType; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::MigrationDetails; use codex_app_server_protocol::PluginsMigration; -use std::io; use std::path::PathBuf; #[derive(Clone)] @@ -38,7 +37,7 @@ impl ExternalAgentConfigApi { cwds: params.cwds, }) .await - .map_err(map_io_error)?; + .map_err(|err| internal_error(err.to_string()))?; Ok(ExternalAgentConfigDetectResponse { items: items @@ -125,7 +124,7 @@ impl ExternalAgentConfigApi { .collect(), ) .await - .map_err(map_io_error) + .map_err(|err| internal_error(err.to_string())) } pub(crate) async fn complete_pending_plugin_import( @@ -139,14 +138,6 @@ impl ExternalAgentConfigApi { ) .await .map(|_| ()) - .map_err(map_io_error) - } -} - -fn map_io_error(err: io::Error) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: err.to_string(), - data: None, + .map_err(|err| internal_error(err.to_string())) } } diff --git a/codex-rs/app-server/src/fs_api.rs b/codex-rs/app-server/src/fs_api.rs index 93b4f21c2b..203b053e5e 100644 --- a/codex-rs/app-server/src/fs_api.rs +++ b/codex-rs/app-server/src/fs_api.rs @@ -1,5 +1,5 @@ -use crate::error_code::INTERNAL_ERROR_CODE; -use crate::error_code::INVALID_REQUEST_ERROR_CODE; +use crate::error_code::internal_error; +use crate::error_code::invalid_request; use base64::Engine; use base64::engine::general_purpose::STANDARD; use codex_app_server_protocol::FsCopyParams; @@ -158,22 +158,10 @@ impl FsApi { } } -pub(crate) fn invalid_request(message: impl Into) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: message.into(), - data: None, - } -} - pub(crate) fn map_fs_error(err: io::Error) -> JSONRPCErrorError { if err.kind() == io::ErrorKind::InvalidInput { invalid_request(err.to_string()) } else { - JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: err.to_string(), - data: None, - } + internal_error(err.to_string()) } } diff --git a/codex-rs/app-server/src/fs_watch.rs b/codex-rs/app-server/src/fs_watch.rs index ff00051472..4ae1ca149e 100644 --- a/codex-rs/app-server/src/fs_watch.rs +++ b/codex-rs/app-server/src/fs_watch.rs @@ -1,4 +1,4 @@ -use crate::fs_api::invalid_request; +use crate::error_code::invalid_request; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::OutgoingMessageSender; use codex_app_server_protocol::FsChangedNotification; diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 729f6d04af..73332394f4 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -77,10 +77,10 @@ use codex_app_server_protocol::Result; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_arg0::Arg0DispatchPaths; +use codex_config::CloudRequirementsLoader; +use codex_config::LoaderOverrides; use codex_config::ThreadConfigLoader; use codex_core::config::Config; -use codex_core::config_loader::CloudRequirementsLoader; -use codex_core::config_loader::LoaderOverrides; use codex_exec_server::EnvironmentManager; use codex_feedback::CodexFeedback; use codex_login::AuthManager; @@ -391,7 +391,8 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { let processor_outgoing = Arc::clone(&outgoing_message_sender); let auth_manager = - AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env); + AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env) + .await; let config_manager = ConfigManager::new( args.config.codex_home.to_path_buf(), args.cli_overrides, @@ -415,6 +416,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { auth_manager, rpc_transport: AppServerRpcTransport::InProcess, remote_control_handle: None, + plugin_startup_tasks: crate::PluginStartupTasks::Start, })); let mut thread_created_rx = processor.thread_created_receiver(); let session = Arc::new(ConnectionSessionState::new(ConnectionOrigin::InProcess)); diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index d9b403165c..de89608101 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; @@ -42,11 +42,11 @@ use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::TextPosition as AppTextPosition; use codex_app_server_protocol::TextRange as AppTextRange; +use codex_config::ConfigLoadError; +use codex_config::TextRange as CoreTextRange; use codex_core::ExecPolicyError; use codex_core::check_execpolicy_for_warnings; use codex_core::config::find_codex_home; -use codex_core::config_loader::ConfigLoadError; -use codex_core::config_loader::TextRange as CoreTextRange; use codex_exec_server::EnvironmentManager; use codex_exec_server::ExecServerRuntimePaths; use codex_feedback::CodexFeedback; @@ -362,6 +362,25 @@ pub async fn run_main( .await } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PluginStartupTasks { + Start, + Skip, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AppServerRuntimeOptions { + pub plugin_startup_tasks: PluginStartupTasks, +} + +impl Default for AppServerRuntimeOptions { + fn default() -> Self { + Self { + plugin_startup_tasks: PluginStartupTasks::Start, + } + } +} + pub async fn run_main_with_transport( arg0_paths: Arg0DispatchPaths, cli_config_overrides: CliConfigOverrides, @@ -370,6 +389,30 @@ pub async fn run_main_with_transport( transport: AppServerTransport, session_source: SessionSource, auth: AppServerWebsocketAuthSettings, +) -> IoResult<()> { + run_main_with_transport_options( + arg0_paths, + cli_config_overrides, + loader_overrides, + default_analytics_enabled, + transport, + session_source, + auth, + AppServerRuntimeOptions::default(), + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub async fn run_main_with_transport_options( + arg0_paths: Arg0DispatchPaths, + cli_config_overrides: CliConfigOverrides, + loader_overrides: LoaderOverrides, + default_analytics_enabled: bool, + transport: AppServerTransport, + session_source: SessionSource, + auth: AppServerWebsocketAuthSettings, + runtime_options: AppServerRuntimeOptions, ) -> IoResult<()> { let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::from_env( ExecServerRuntimePaths::from_optional_paths( @@ -426,7 +469,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) => { @@ -475,7 +518,7 @@ pub async fn run_main_with_transport( }); } if let Some(warning) = - codex_core::config::system_bwrap_warning(config.permissions.sandbox_policy.get()) + codex_core::config::system_bwrap_warning(config.permissions.permission_profile.get()) { config_warnings.push(ConfigWarningNotification { summary: warning, @@ -588,7 +631,7 @@ pub async fn run_main_with_transport( } let auth_manager = - AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false); + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; let remote_control_enabled = config.features.enabled(Feature::RemoteControl); if transport_accept_handles.is_empty() && !remote_control_enabled { @@ -669,7 +712,7 @@ pub async fn run_main_with_transport( let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(outgoing_tx)); let outbound_control_tx = outbound_control_tx; let auth_manager = - AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false); + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; let processor = Arc::new(MessageProcessor::new(MessageProcessorArgs { outgoing: outgoing_message_sender, arg0_paths, @@ -683,6 +726,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 d3eee87ccd..6cdb939364 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -10,7 +10,7 @@ use crate::codex_message_processor::CodexMessageProcessorArgs; use crate::config_api::ConfigApi; use crate::config_manager::ConfigManager; use crate::device_key_api::DeviceKeyApi; -use crate::error_code::INVALID_REQUEST_ERROR_CODE; +use crate::error_code::invalid_request; use crate::external_agent_config_api::ExternalAgentConfigApi; use crate::fs_api::FsApi; use crate::fs_watch::FsWatchManager; @@ -34,7 +34,6 @@ use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientNotification; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigBatchWriteParams; -use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::DeviceKeyCreateParams; @@ -42,20 +41,10 @@ use codex_app_server_protocol::DeviceKeyPublicParams; use codex_app_server_protocol::DeviceKeySignParams; use codex_app_server_protocol::ExperimentalApi; use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; -use codex_app_server_protocol::ExternalAgentConfigDetectParams; use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification; use codex_app_server_protocol::ExternalAgentConfigImportParams; use codex_app_server_protocol::ExternalAgentConfigImportResponse; use codex_app_server_protocol::ExternalAgentConfigMigrationItemType; -use codex_app_server_protocol::FsCopyParams; -use codex_app_server_protocol::FsCreateDirectoryParams; -use codex_app_server_protocol::FsGetMetadataParams; -use codex_app_server_protocol::FsReadDirectoryParams; -use codex_app_server_protocol::FsReadFileParams; -use codex_app_server_protocol::FsRemoveParams; -use codex_app_server_protocol::FsUnwatchParams; -use codex_app_server_protocol::FsWatchParams; -use codex_app_server_protocol::FsWriteFileParams; use codex_app_server_protocol::InitializeResponse; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCErrorError; @@ -95,7 +84,6 @@ use tokio::time::timeout; use tracing::Instrument; const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); - #[derive(Clone)] struct ExternalAuthRefreshBridge { outgoing: Arc, @@ -260,6 +248,7 @@ pub(crate) struct MessageProcessorArgs { pub(crate) auth_manager: Arc, pub(crate) rpc_transport: AppServerRpcTransport, pub(crate) remote_control_handle: Option, + pub(crate) plugin_startup_tasks: crate::PluginStartupTasks, } impl MessageProcessor { @@ -279,6 +268,7 @@ impl MessageProcessor { auth_manager, rpc_transport, remote_control_handle, + plugin_startup_tasks, } = args; auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), @@ -315,11 +305,13 @@ 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(), @@ -387,43 +379,28 @@ impl MessageProcessor { Arc::clone(&self.outgoing), request_context.clone(), async { - let request_json = match serde_json::to_value(&request) { - Ok(request_json) => request_json, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("Invalid request: {err}"), - data: None, - }; - self.outgoing.send_error(request_id.clone(), error).await; - return; - } - }; - - let codex_request = match serde_json::from_value::(request_json) { - Ok(codex_request) => codex_request, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("Invalid request: {err}"), - data: None, - }; - self.outgoing.send_error(request_id.clone(), error).await; - return; - } - }; - // Websocket callers finalize outbound readiness in lib.rs after mirroring - // session state into outbound state and sending initialize notifications to - // this specific connection. Passing `None` avoids marking the connection - // ready too early from inside the shared request handler. - self.handle_client_request( - request_id.clone(), - codex_request, - Arc::clone(&session), - /*outbound_initialized*/ None, - request_context.clone(), - ) + let result = async { + let request_json = serde_json::to_value(&request) + .map_err(|err| invalid_request(format!("Invalid request: {err}")))?; + let codex_request = serde_json::from_value::(request_json) + .map_err(|err| invalid_request(format!("Invalid request: {err}")))?; + // Websocket callers finalize outbound readiness in lib.rs after mirroring + // session state into outbound state and sending initialize notifications to + // this specific connection. Passing `None` avoids marking the connection + // ready too early from inside the shared request handler. + self.handle_client_request( + request_id.clone(), + codex_request, + Arc::clone(&session), + /*outbound_initialized*/ None, + request_context.clone(), + ) + .await + } .await; + if let Err(error) = result { + self.outgoing.send_error(request_id.clone(), error).await; + } }, ) .await; @@ -460,14 +437,18 @@ impl MessageProcessor { // In-process clients do not have the websocket transport loop that performs // post-initialize bookkeeping, so they still finalize outbound readiness in // the shared request handler. - self.handle_client_request( - request_id.clone(), - request, - Arc::clone(&session), - Some(outbound_initialized), - request_context.clone(), - ) - .await; + let result = self + .handle_client_request( + request_id.clone(), + request, + Arc::clone(&session), + Some(outbound_initialized), + request_context.clone(), + ) + .await; + if let Err(error) = result { + self.outgoing.send_error(request_id.clone(), error).await; + } }, ) .await; @@ -595,7 +576,7 @@ impl MessageProcessor { // lib.rs can deliver connection-scoped initialize notifications first. outbound_initialized: Option<&AtomicBool>, request_context: RequestContext, - ) { + ) -> Result<(), JSONRPCErrorError> { let connection_id = connection_request_id.connection_id; if let ClientRequest::Initialize { request_id, params } = codex_request { // Handle Initialize internally so CodexMessageProcessor does not have to concern @@ -605,13 +586,7 @@ impl MessageProcessor { request_id, }; if session.initialized() { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "Already initialized".to_string(), - data: None, - }; - self.outgoing.send_error(connection_request_id, error).await; - return; + return Err(invalid_request("Already initialized")); } // TODO(maxj): Revisit capability scoping for `experimental_api_enabled`. @@ -639,17 +614,9 @@ impl MessageProcessor { // Validate before committing; set_default_originator validates while // mutating process-global metadata. if HeaderValue::from_str(&name).is_err() { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!( - "Invalid clientInfo.name: '{name}'. Must be a valid HTTP header value." - ), - data: None, - }; - self.outgoing - .send_error(connection_request_id.clone(), error) - .await; - return; + return Err(invalid_request(format!( + "Invalid clientInfo.name: '{name}'. Must be a valid HTTP header value." + ))); } let originator = name.clone(); let user_agent_suffix = format!("{name}; {version}"); @@ -665,13 +632,7 @@ impl MessageProcessor { }) .is_err() { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "Already initialized".to_string(), - data: None, - }; - self.outgoing.send_error(connection_request_id, error).await; - return; + return Err(invalid_request("Already initialized")); } // Only the request that wins session initialization may mutate @@ -726,7 +687,7 @@ impl MessageProcessor { .connection_initialized(connection_id) .await; } - return; + return Ok(()); } self.dispatch_initialized_client_request( @@ -735,7 +696,7 @@ impl MessageProcessor { session, request_context, ) - .await; + .await } async fn dispatch_initialized_client_request( @@ -744,27 +705,15 @@ 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) @@ -790,7 +739,7 @@ impl MessageProcessor { client_version, device_key_requests_allowed, ) - .await; + .await } async fn handle_initialized_client_request( @@ -801,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; @@ -869,133 +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, ); } 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, ); } 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, ); } 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 @@ -1013,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( @@ -1164,13 +1061,6 @@ 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, - } - } - fn handle_device_key_create( &self, request_id: ConnectionRequestId, @@ -1227,193 +1117,80 @@ impl MessageProcessor { let device_key_api = self.device_key_api.clone(); let outgoing = Arc::clone(&self.outgoing); tokio::spawn(async move { - if !device_key_requests_allowed { - outgoing - .send_error( - request_id, - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("{method} is not available over remote transports"), - data: None, - }, - ) - .await; - return; - } - - match run_request(device_key_api).await { - Ok(response) => outgoing.send_response(request_id, response).await, - Err(error) => outgoing.send_error(request_id, error).await, + 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; + outgoing.send_result(request_id, result).await; }); } - 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, - } - } - 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 5b6690c0ba..850d05c04e 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -27,10 +27,10 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput; use codex_arg0::Arg0DispatchPaths; +use codex_config::CloudRequirementsLoader; +use codex_config::LoaderOverrides; use codex_core::config::Config; use codex_core::config::ConfigBuilder; -use codex_core::config_loader::CloudRequirementsLoader; -use codex_core::config_loader::LoaderOverrides; use codex_exec_server::EnvironmentManager; use codex_feedback::CodexFeedback; use codex_login::AuthManager; @@ -127,7 +127,7 @@ impl TracingHarness { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; let config = Arc::new(build_test_config(codex_home.path(), &server.uri()).await?); - let (processor, outgoing_rx) = build_test_processor(config); + let (processor, outgoing_rx) = build_test_processor(config).await; let tracing = init_test_tracing(); tracing.exporter.reset(); tracing::callsite::rebuild_interest_cache(); @@ -257,7 +257,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 outgoing = Arc::new(OutgoingMessageSender::new(outgoing_tx)); let auth_manager = - AuthManager::shared_from_config(config.as_ref(), /*enable_codex_api_key_env*/ false); + AuthManager::shared_from_config(config.as_ref(), /*enable_codex_api_key_env*/ false).await; let config_manager = ConfigManager::new( config.codex_home.to_path_buf(), Vec::new(), @@ -288,6 +288,7 @@ fn build_test_processor( auth_manager, rpc_transport: AppServerRpcTransport::Stdio, remote_control_handle: None, + plugin_startup_tasks: crate::PluginStartupTasks::Start, })); (processor, outgoing_rx) } diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 4d073fc5a6..6ca1fcabaf 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -22,6 +22,7 @@ use tracing::Span; use tracing::warn; use crate::error_code::INTERNAL_ERROR_CODE; +use crate::error_code::internal_error; use crate::server_request_error::TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON; #[cfg(test)] @@ -196,7 +197,7 @@ impl ThreadScopedOutgoingMessageSender { pub(crate) async fn send_error( &self, request_id: ConnectionRequestId, - error: JSONRPCErrorError, + error: impl Into, ) { self.outgoing.send_error(request_id, error).await; } @@ -493,11 +494,7 @@ impl OutgoingMessageSender { self.send_error_inner( request_context, request_id, - JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to serialize response: {err}"), - data: None, - }, + internal_error(format!("failed to serialize response: {err}")), ) .await; } @@ -571,13 +568,27 @@ impl OutgoingMessageSender { pub(crate) async fn send_error( &self, request_id: ConnectionRequestId, - error: JSONRPCErrorError, + error: impl Into, ) { let request_context = self.take_request_context(&request_id).await; - self.send_error_inner(request_context, request_id, error) + self.send_error_inner(request_context, request_id, error.into()) .await; } + pub(crate) async fn send_result( + &self, + request_id: ConnectionRequestId, + result: std::result::Result, + ) where + T: Serialize, + E: Into, + { + match result { + Ok(response) => self.send_response(request_id, response).await, + Err(error) => self.send_error(request_id, error).await, + } + } + async fn send_error_inner( &self, request_context: Option, diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index d4347933ef..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; @@ -9,6 +10,7 @@ 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; @@ -31,6 +33,8 @@ pub(crate) struct PendingThreadResumeRequest { 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 { 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/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 464832e34a..4cd078455a 100644 --- a/codex-rs/app-server/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server/src/transport/remote_control/websocket.rs @@ -706,7 +706,7 @@ pub(crate) async fn load_remote_control_auth( "remote control requires ChatGPT authentication", )); } - auth_manager.reload(); + auth_manager.reload().await; reloaded = true; continue; }; @@ -714,7 +714,7 @@ pub(crate) async fn load_remote_control_auth( break auth; } if auth.get_account_id().is_none() && !reloaded { - auth_manager.reload(); + auth_manager.reload().await; reloaded = true; continue; } @@ -1090,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(), @@ -1172,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/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/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/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 30b4c0f325..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 @@ -16,9 +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; 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_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index f885f2cb7a..8735c20ff6 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -1066,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?; @@ -1102,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??; } @@ -1490,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?; 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/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 f3ee3d4dcc..9c32ba5eaa 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -28,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; @@ -62,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; @@ -91,6 +95,10 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs 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, @@ -168,6 +176,63 @@ 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( + "general_analytics = true\n", + "general_analytics = 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; @@ -326,6 +391,344 @@ 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( + "general_analytics = true\n", + "general_analytics = 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( + "general_analytics = true\n", + "general_analytics = 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( + "general_analytics = true\n", + "general_analytics = 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; @@ -1321,7 +1724,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() @@ -2203,7 +2605,12 @@ 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(()) @@ -2243,9 +2650,12 @@ async fn thread_resume_can_load_source_by_external_path() -> Result<()> { let ThreadResumeResponse { thread: resumed, .. } = to_response::(resume_resp)?; - let expected_thread_path = std::fs::canonicalize(&thread_path)?; assert_eq!(resumed.id, thread_id); - assert_eq!(resumed.path, Some(expected_thread_path)); + 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); @@ -2269,7 +2679,6 @@ async fn thread_resume_supports_history_and_overrides() -> Result<()> { content: vec![ContentItem::InputText { text: history_text.to_string(), }], - end_turn: None, phase: None, }]; diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 3177003ddb..f521d5509c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -20,9 +20,9 @@ use codex_app_server_protocol::ThreadStartedNotification; use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::ThreadStatusChangedNotification; use codex_app_server_protocol::TurnEnvironmentParams; +use codex_config::loader::project_trust_key; use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::set_project_trust_level; -use codex_core::config_loader::project_trust_key; use codex_exec_server::LOCAL_FS; use codex_git_utils::resolve_root_git_project_for_trust; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; 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 04e6ede0c5..6d66edd3d3 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -749,13 +749,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??; @@ -772,7 +776,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 @@ -787,7 +791,7 @@ async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() -> file_system: PermissionProfileFileSystemPermissions::Restricted { entries: vec![FileSystemSandboxEntry { path: FileSystemPath::Path { - path: unsupported_write_root, + path: disallowed_write_root, }, access: FileSystemAccessMode::Write, }], @@ -806,9 +810,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), @@ -1895,7 +1899,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, 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/chatgpt/src/chatgpt_client.rs b/codex-rs/chatgpt/src/chatgpt_client.rs index 42aac41138..05d8186686 100644 --- a/codex-rs/chatgpt/src/chatgpt_client.rs +++ b/codex-rs/chatgpt/src/chatgpt_client.rs @@ -21,7 +21,7 @@ pub(crate) async fn chatgpt_get_request_with_timeout( ) -> anyhow::Result { let chatgpt_base_url = &config.chatgpt_base_url; 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 diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 62e8040940..9dba71ce3a 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -26,7 +26,7 @@ const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60); async fn apps_enabled(config: &Config) -> bool { 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; config .features @@ -35,7 +35,7 @@ async fn apps_enabled(config: &Config) -> bool { async fn connector_auth(config: &Config) -> 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 diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 2a9c5a6ff7..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,7 +38,6 @@ 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 } 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 2481ecd6fe..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; @@ -43,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; @@ -163,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), @@ -366,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, @@ -829,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, @@ -947,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, @@ -962,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; } @@ -1130,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(), @@ -1382,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 @@ -1549,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, ) @@ -1837,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/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 8c51888a16..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; @@ -329,6 +329,11 @@ 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); }; @@ -722,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, @@ -733,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) } @@ -848,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", @@ -856,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>, @@ -881,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( @@ -970,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>, @@ -990,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 { @@ -1113,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, @@ -1129,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, @@ -1142,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()), }), @@ -1156,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()), }), @@ -1188,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()), }), @@ -1220,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()), }), @@ -1309,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), }, )]), @@ -1324,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, @@ -1351,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, @@ -1400,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(), @@ -1474,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(), @@ -1554,7 +1576,8 @@ enabled = false Some("account-12345"), "stale-access-token", "test-refresh-token", - ); + ) + .await; write_auth_json( auth._home.path(), chatgpt_auth_json( @@ -1606,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: @@ -1648,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, @@ -1673,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, @@ -1695,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()), }), @@ -1706,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, @@ -1742,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()), }), @@ -1785,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()), }), @@ -1802,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, @@ -1838,7 +1866,8 @@ enabled = false "business", Some("user-12345"), Some("account-12345"), - ), + ) + .await, Arc::new(StaticFetcher { contents: Some("allowed_approval_policies = [\"never\"]".to_string()), }), @@ -1855,7 +1884,8 @@ enabled = false "business", Some("user-99999"), Some("account-12345"), - ), + ) + .await, fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, @@ -1887,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()), }), @@ -1912,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, @@ -1970,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, @@ -2002,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()), }), @@ -2065,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, @@ -2083,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, @@ -2116,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/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index e433b892e5..9a5056aa66 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -44,12 +44,15 @@ pub fn normalize_base_url(input: &str) -> String { 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, - chatgpt_base_url.or(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`, 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 a9aacb1925..c3061adca9 100644 --- a/codex-rs/codex-mcp/Cargo.toml +++ b/codex-rs/codex-mcp/Cargo.toml @@ -38,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 9c605c16fb..6a97b52789 100644 --- a/codex-rs/codex-mcp/src/mcp/auth.rs +++ b/codex-rs/codex-mcp/src/mcp/auth.rs @@ -43,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, @@ -119,12 +125,6 @@ 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, diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 1061a6a542..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,67 +178,6 @@ 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 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) -> 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(), - } -} - pub fn with_codex_apps_mcp( mut servers: HashMap, auth: Option<&CodexAuth>, @@ -313,7 +234,7 @@ 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), @@ -335,78 +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, - auth, - ) - .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, - auth, - ) - .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>, @@ -415,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>, @@ -466,7 +299,7 @@ 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), @@ -487,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 { @@ -664,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 4dd703c389..0000000000 --- a/codex-rs/codex-mcp/src/mcp_connection_manager.rs +++ /dev/null @@ -1,1879 +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_api::SharedAuthProvider; -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_exec_server::HttpClient; -use codex_exec_server::ReqwestHttpClient; -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 { - 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), - } -} - -#[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, - 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, - } - } - - 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, - 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_sandbox_policy); - 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) - } - - 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, - 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) - } - } -} - -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 9ee7841173..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. @@ -629,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() @@ -668,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, @@ -690,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/key_aliases.rs b/codex-rs/config/src/key_aliases.rs index 8d417e269f..07cb44fa6d 100644 --- a/codex-rs/config/src/key_aliases.rs +++ b/codex-rs/config/src/key_aliases.rs @@ -8,18 +8,11 @@ struct ConfigKeyAlias { canonical_key: &'static str, } -const CONFIG_KEY_ALIASES: &[ConfigKeyAlias] = &[ - ConfigKeyAlias { - table_path: &["memories"], - legacy_key: "no_memories_if_mcp_or_web_search", - canonical_key: "disable_on_external_context", - }, - ConfigKeyAlias { - table_path: &["agents"], - legacy_key: "max_concurrent_threads_per_session", - canonical_key: "max_threads", - }, -]; +const CONFIG_KEY_ALIASES: &[ConfigKeyAlias] = &[ConfigKeyAlias { + table_path: &["memories"], + legacy_key: "no_memories_if_mcp_or_web_search", + canonical_key: "disable_on_external_context", +}]; pub(crate) fn normalize_key_aliases(path: &[String], table: &mut TomlMap) { for alias in CONFIG_KEY_ALIASES { 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/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/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-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/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 6cac429083..5727a4bdcf 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -424,6 +424,9 @@ "general_analytics": { "type": "boolean" }, + "goals": { + "type": "boolean" + }, "guardian_approval": { "type": "boolean" }, @@ -523,6 +526,9 @@ "telepathy": { "type": "boolean" }, + "terminal_resize_reflow": { + "type": "boolean" + }, "tool_call_mcp_elicitation": { "type": "boolean" }, @@ -583,16 +589,6 @@ "include_permissions_instructions": { "type": "boolean" }, - "js_repl_node_module_dirs": { - "description": "Ordered list of directories to search for Node modules in `js_repl`.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - }, - "js_repl_node_path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, "model": { "type": "string" }, @@ -1314,6 +1310,11 @@ "hide_spawn_agent_metadata": { "type": "boolean" }, + "max_concurrent_threads_per_session": { + "format": "uint", + "minimum": 1.0, + "type": "integer" + }, "usage_hint_enabled": { "type": "boolean" }, @@ -2093,6 +2094,42 @@ }, "type": "object" }, + "ThreadStoreToml": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "local" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "endpoint": { + "type": "string" + }, + "type": { + "enum": [ + "remote" + ], + "type": "string" + } + }, + "required": [ + "endpoint", + "type" + ], + "type": "object" + } + ] + }, "ToolSuggestConfig": { "additionalProperties": false, "properties": { @@ -2223,6 +2260,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`.", @@ -2489,6 +2533,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" @@ -2582,6 +2634,9 @@ "general_analytics": { "type": "boolean" }, + "goals": { + "type": "boolean" + }, "guardian_approval": { "type": "boolean" }, @@ -2681,6 +2736,9 @@ "telepathy": { "type": "boolean" }, + "terminal_resize_reflow": { + "type": "boolean" + }, "tool_call_mcp_elicitation": { "type": "boolean" }, @@ -2805,21 +2863,6 @@ "description": "System instructions.", "type": "string" }, - "js_repl_node_module_dirs": { - "description": "Ordered list of directories to search for Node modules in `js_repl`.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - }, - "js_repl_node_path": { - "allOf": [ - { - "$ref": "#/definitions/AbsolutePathBuf" - } - ], - "description": "Optional absolute path to the Node runtime used by `js_repl`." - }, "log_dir": { "allOf": [ { diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 6018c37474..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, }, ) @@ -678,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)), 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 5828354647..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 }` containing encoded PNG/JPEG/WebP/GIF bytes, 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 e163eba1f6..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 }` containing encoded PNG/JPEG/WebP/GIF bytes, 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 }` containing encoded PNG/JPEG/WebP/GIF bytes, 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_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 cb63ca4551..c49e28f20a 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -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_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..e9218ae7f0 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -509,7 +509,6 @@ fn build_compacted_history_with_limit( content: vec![ContentItem::InputText { text: message.clone(), }], - end_turn: None, phase: None, }); } @@ -524,7 +523,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_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 898ddef8cc..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; 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 7af47fe5e4..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,14 +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::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; @@ -519,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( @@ -756,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 { @@ -785,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(()) @@ -823,12 +840,151 @@ 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(), + &config.legacy_sandbox_policy(), &SandboxPolicy::DangerFullAccess ); Ok(()) } +#[tokio::test] +async fn permission_profile_override_preserves_managed_unrestricted_filesystem() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let permission_profile = PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Unrestricted, + network: NetworkSandboxPolicy::Restricted, + }; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + permission_profile: Some(permission_profile.clone()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + assert_eq!(config.permissions.permission_profile(), permission_profile); + assert_eq!( + &config.legacy_sandbox_policy(), + &SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + } + ); + Ok(()) +} + +#[tokio::test] +async fn managed_unrestricted_permission_profile_still_enables_network_requirements() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let permission_profile = PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Unrestricted, + network: NetworkSandboxPolicy::Enabled, + }; + + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + permission_profile: Some(permission_profile), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + assert_eq!( + &config.legacy_sandbox_policy(), + &SandboxPolicy::DangerFullAccess, + "the legacy projection is intentionally lossy for managed unrestricted profiles" + ); + + let layers = config + .config_layer_stack + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) + .into_iter() + .cloned() + .collect(); + let mut requirements = config.config_layer_stack.requirements().clone(); + requirements.network = Some(Sourced::new( + codex_config::NetworkConstraints { + enabled: Some(true), + ..Default::default() + }, + RequirementSource::CloudRequirements, + )); + let mut requirements_toml = config.config_layer_stack.requirements_toml().clone(); + requirements_toml.network = Some(codex_config::NetworkRequirementsToml { + enabled: Some(true), + ..Default::default() + }); + config.config_layer_stack = ConfigLayerStack::new(layers, requirements, requirements_toml) + .expect("config layer stack with network requirements"); + + assert!(config.managed_network_requirements_enabled()); + Ok(()) +} + +#[tokio::test] +async fn permission_profile_override_applies_runtime_roots_to_legacy_projection() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let permission_profile = PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::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(()) +} + #[tokio::test] async fn permission_profile_override_preserves_configured_network_proxy() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -927,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) ); @@ -937,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 { @@ -949,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!( @@ -1006,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 { @@ -1022,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), )]), }), @@ -1038,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(()) } @@ -1135,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( @@ -1147,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, } ); @@ -1185,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())), @@ -1212,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, } ); @@ -1247,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!( @@ -1344,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(()) } @@ -1400,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#" @@ -1421,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); @@ -1442,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()); @@ -1474,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") { @@ -1484,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, @@ -1515,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") { @@ -1525,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, @@ -1580,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, + 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" @@ -1763,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 @@ -1826,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:?}"), } @@ -1837,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 @@ -2040,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); } @@ -2077,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(()) @@ -2155,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 )); @@ -2189,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 { .. } )); } @@ -2318,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 = @@ -2454,7 +2667,6 @@ async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> { overrides, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, - /*host_name*/ None, ) .await?; @@ -3763,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(), ), @@ -3844,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() }, @@ -3908,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() }, @@ -4040,15 +4252,15 @@ config_file = "./agents/researcher.toml" "#, ) .expect("agent role layer config should parse"); - let config_layer_stack = crate::config_loader::ConfigLayerStack::new( - vec![crate::config_loader::ConfigLayerEntry::new( + 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(), - crate::config_loader::ConfigRequirementsToml::default(), + codex_config::ConfigRequirementsToml::default(), ) .map_err(std::io::Error::other)?; @@ -5229,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(), @@ -5274,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, @@ -5294,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, @@ -5326,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(), @@ -5427,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(), @@ -5472,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, @@ -5492,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, @@ -5524,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(), @@ -5579,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(), @@ -5624,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, @@ -5644,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, @@ -5676,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(), @@ -5716,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(), @@ -5761,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, @@ -5781,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, @@ -5813,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(), @@ -5833,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, @@ -5851,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| { @@ -5866,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( @@ -6126,7 +6316,7 @@ trust_level = "untrusted" /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, Some(&active_project), - /*sandbox_policy_constraint*/ None, + /*permission_profile_constraint*/ None, ) .await; @@ -6147,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(); @@ -6164,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, }) } @@ -6187,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(()) } @@ -6209,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( @@ -6460,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" @@ -6468,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" @@ -6486,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(()) @@ -6511,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, @@ -6537,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<()> { @@ -6557,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() })) @@ -6571,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, ); @@ -6598,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() })) @@ -6627,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() })) @@ -6648,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), @@ -6682,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() @@ -6704,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), @@ -6739,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), @@ -6886,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() })) @@ -6912,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() })) @@ -6949,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() })) @@ -6975,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, @@ -7062,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 @@ -7075,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(), @@ -7093,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 @@ -7110,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(), @@ -7120,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()?; @@ -7127,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), @@ -7163,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() @@ -7193,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 9bdbeb9d1c..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,6 +75,7 @@ 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; @@ -114,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; @@ -127,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"; @@ -190,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. @@ -222,12 +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_with_enforcement( - SandboxEnforcement::from_legacy_sandbox_policy(self.sandbox_policy.get()), - &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. @@ -380,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. @@ -481,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, @@ -545,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, @@ -628,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, @@ -636,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, @@ -643,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() @@ -661,7 +757,7 @@ impl AuthManagerConfig for Config { } } -#[derive(Clone)] +#[derive(Clone, Default)] pub struct ConfigBuilder { codex_home: Option, cli_overrides: Option>, @@ -670,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 { @@ -727,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, @@ -741,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)?, @@ -766,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(); @@ -778,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), @@ -807,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, @@ -881,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( @@ -946,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?; @@ -1128,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(); @@ -1297,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, @@ -1349,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() { @@ -1394,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, @@ -1484,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)) @@ -1499,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, @@ -1514,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 [ @@ -1583,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: _, @@ -1614,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, @@ -1756,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 = @@ -1785,28 +1900,28 @@ impl Config { } 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(|| { @@ -1831,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(); @@ -1856,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 { @@ -1872,11 +1996,15 @@ impl Config { 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() @@ -1923,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) @@ -1958,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() @@ -2149,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)); @@ -2188,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, @@ -2203,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)?; @@ -2231,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( @@ -2251,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() { @@ -2276,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, .. @@ -2299,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, @@ -2317,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, @@ -2386,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), @@ -2434,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, @@ -2486,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); @@ -2570,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() @@ -2642,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/connectors.rs b/codex-rs/core/src/connectors.rs index 968b93214c..42cd415217 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -17,7 +17,7 @@ use codex_connectors::DirectoryListResponse; use codex_exec_server::EnvironmentManager; use codex_exec_server::EnvironmentManagerArgs; use codex_exec_server::ExecServerRuntimePaths; -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 @@ -274,7 +274,7 @@ 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()), @@ -434,7 +434,7 @@ async fn list_directory_connectors_for_tool_suggest_with_auth( Some(auth) } else { 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; loaded_auth = auth_manager.auth().await; loaded_auth.as_ref() }; 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_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index bd8e77fd24..f5c30af575 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -41,7 +41,6 @@ fn assistant_msg(text: &str) -> ResponseItem { content: vec![ContentItem::OutputText { text: text.to_string(), }], - end_turn: None, phase: None, } } @@ -60,7 +59,6 @@ fn inter_agent_assistant_msg(text: &str) -> ResponseItem { content: vec![ContentItem::OutputText { text: serde_json::to_string(&communication).unwrap(), }], - end_turn: None, phase: None, } } @@ -80,7 +78,6 @@ fn user_msg(text: &str) -> ResponseItem { content: vec![ContentItem::OutputText { text: text.to_string(), }], - end_turn: None, phase: None, } } @@ -92,7 +89,6 @@ fn user_input_text_msg(text: &str) -> ResponseItem { content: vec![ContentItem::InputText { text: text.to_string(), }], - end_turn: None, phase: None, } } @@ -104,7 +100,6 @@ fn developer_msg(text: &str) -> ResponseItem { content: vec![ContentItem::InputText { text: text.to_string(), }], - end_turn: None, phase: None, } } @@ -119,7 +114,6 @@ fn developer_msg_with_fragments(texts: &[&str]) -> ResponseItem { text: (*text).to_string(), }) .collect(), - end_turn: None, phase: None, } } @@ -200,7 +194,6 @@ fn filters_non_api_messages() { content: vec![ContentItem::OutputText { text: "ignored".to_string(), }], - end_turn: None, phase: None, }; let reasoning = reasoning_msg("thinking..."); @@ -231,7 +224,6 @@ fn filters_non_api_messages() { content: vec![ContentItem::OutputText { text: "hi".to_string() }], - end_turn: None, phase: None, }, ResponseItem::Message { @@ -240,7 +232,6 @@ fn filters_non_api_messages() { content: vec![ContentItem::OutputText { text: "hello".to_string() }], - end_turn: None, phase: None, } ] @@ -390,7 +381,6 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { text: "caption".to_string(), }, ], - end_turn: None, phase: None, }, ResponseItem::FunctionCall { @@ -453,7 +443,6 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { text: "caption".to_string(), }, ], - end_turn: None, phase: None, }, ResponseItem::FunctionCall { @@ -512,7 +501,6 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ], - end_turn: None, phase: None, }]); let preserved = with_images.for_prompt(&modalities); @@ -540,7 +528,6 @@ fn for_prompt_preserves_image_generation_calls_when_images_are_supported() { content: vec![ContentItem::InputText { text: "hi".to_string(), }], - end_turn: None, phase: None, }, ]); @@ -560,7 +547,6 @@ fn for_prompt_preserves_image_generation_calls_when_images_are_supported() { content: vec![ContentItem::InputText { text: "hi".to_string(), }], - end_turn: None, phase: None, } ] @@ -576,7 +562,6 @@ fn for_prompt_clears_image_generation_result_when_images_are_unsupported() { content: vec![ContentItem::InputText { text: "generate a lobster".to_string(), }], - end_turn: None, phase: None, }, ResponseItem::ImageGenerationCall { @@ -596,7 +581,6 @@ fn for_prompt_clears_image_generation_result_when_images_are_unsupported() { content: vec![ContentItem::InputText { text: "generate a lobster".to_string(), }], - end_turn: None, phase: None, }, ResponseItem::ImageGenerationCall { @@ -758,7 +742,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 +1673,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 +1681,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 +1754,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 +1785,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 +1825,6 @@ fn mixed_case_data_url_markers_are_adjusted() { image_url, detail: Some(DEFAULT_IMAGE_DETAIL), }], - end_turn: None, phase: None, }; @@ -1879,7 +1857,6 @@ fn multiple_inline_images_apply_multiple_fixed_costs() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ], - end_turn: None, phase: None, }; @@ -1962,7 +1939,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\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