diff --git a/.bazelrc b/.bazelrc index 2ff9cbc28e..3a49a22c20 100644 --- a/.bazelrc +++ b/.bazelrc @@ -20,9 +20,6 @@ common:windows --host_platform=//:local_windows common --@rules_cc//cc/toolchains/args/archiver_flags:use_libtool_on_macos=False common --@llvm//config:experimental_stub_libgcc_s -# We need to use the sh toolchain on windows so we don't send host bash paths to the linux executor. -common:windows --@rules_rust//rust/settings:experimental_use_sh_toolchain_for_bootstrap_process_wrapper - # TODO(zbarsky): rules_rust doesn't implement this flag properly with remote exec... # common --@rules_rust//rust/settings:pipelined_compilation @@ -47,6 +44,7 @@ common --remote_timeout=3600 common --noexperimental_throttle_remote_action_building common --experimental_remote_execution_keepalive common --grpc_keepalive_time=30s +common --experimental_remote_downloader=grpcs://remote.buildbuddy.io # This limits both in-flight executions and concurrent downloads. Even with high number # of jobs execution will still be limited by CPU cores, so this just pays a bit of @@ -60,3 +58,98 @@ common:remote --jobs=800 # Enable pipelined compilation since we are not bound by local CPU count. #common:remote --@rules_rust//rust/settings:pipelined_compilation +# GitHub Actions CI configs. +common:ci --remote_download_minimal +common:ci --keep_going +common:ci --verbose_failures +common:ci --build_metadata=REPO_URL=https://github.com/openai/codex.git +common:ci --build_metadata=ROLE=CI +common:ci --build_metadata=VISIBILITY=PUBLIC + +# Disable disk cache in CI since we have a remote one and aren't using persistent workers. +common:ci --disk_cache= + +# Shared config for the main Bazel CI workflow. +common:ci-bazel --config=ci +common:ci-bazel --build_metadata=TAG_workflow=bazel + +# Shared config for Bazel-backed Rust linting. +build:clippy --aspects=@rules_rust//rust:defs.bzl%rust_clippy_aspect +build:clippy --output_groups=+clippy_checks +build:clippy --@rules_rust//rust/settings:clippy.toml=//codex-rs:clippy.toml +# Keep this deny-list in sync with `codex-rs/Cargo.toml` `[workspace.lints.clippy]`. +# Cargo applies those lint levels to member crates that opt into `[lints] workspace = true` +# in their own `Cargo.toml`, but `rules_rust` Bazel clippy does not read Cargo lint levels. +# `clippy.toml` can configure lint behavior, but it cannot set allow/warn/deny/forbid levels. +build:clippy --@rules_rust//rust/settings:clippy_flag=-Dwarnings +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::expect_used +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::identity_op +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_clamp +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_filter +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_find +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_flatten +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_map +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_memcpy +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_non_exhaustive +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_ok_or +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_range_contains +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_retain +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_strip +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_try_fold +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::manual_unwrap_or +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::needless_borrow +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::needless_borrowed_reference +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::needless_collect +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::needless_late_init +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::needless_option_as_deref +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::needless_question_mark +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::needless_update +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::redundant_clone +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::redundant_closure +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::redundant_closure_for_method_calls +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::redundant_static_lifetimes +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::trivially_copy_pass_by_ref +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::uninlined_format_args +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::unnecessary_filter_map +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::unnecessary_lazy_evaluations +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::unnecessary_sort_by +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::unnecessary_to_owned +build:clippy --@rules_rust//rust/settings:clippy_flag=--deny=clippy::unwrap_used + +# Shared config for Bazel-backed argument-comment-lint. +build:argument-comment-lint --aspects=//tools/argument-comment-lint:lint_aspect.bzl%rust_argument_comment_lint_aspect +build:argument-comment-lint --output_groups=argument_comment_lint_checks +build:argument-comment-lint --@rules_rust//rust/toolchain/channel=nightly + +# Rearrange caches on Windows so they're on the same volume as the checkout. +common:ci-windows --config=ci-bazel +common:ci-windows --build_metadata=TAG_os=windows +common:ci-windows --repo_contents_cache=D:/a/.cache/bazel-repo-contents-cache + +# We prefer to run the build actions entirely remotely so we can dial up the concurrency. +# We have platform-specific tests, so we want to execute the tests on all platforms using the strongest sandboxing available on each platform. + +# On linux, we can do a full remote build/test, by targeting the right (x86/arm) runners, so we have coverage of both. +# Linux crossbuilds don't work until we untangle the libc constraint mess. +common:ci-linux --config=ci-bazel +common:ci-linux --build_metadata=TAG_os=linux +common:ci-linux --config=remote +common:ci-linux --strategy=remote +common:ci-linux --platforms=//:rbe + +# On mac, we can run all the build actions remotely but test actions locally. +common:ci-macos --config=ci-bazel +common:ci-macos --build_metadata=TAG_os=macos +common:ci-macos --config=remote +common:ci-macos --strategy=remote +common:ci-macos --strategy=TestRunner=darwin-sandbox,local + +# Linux-only V8 CI config. +common:ci-v8 --config=ci +common:ci-v8 --build_metadata=TAG_workflow=v8 +common:ci-v8 --build_metadata=TAG_os=linux +common:ci-v8 --config=remote +common:ci-v8 --strategy=remote + +# Optional per-user local overrides. +try-import %workspace%/user.bazelrc diff --git a/.codespellrc b/.codespellrc index 4f3067ed76..87e3468c66 100644 --- a/.codespellrc +++ b/.codespellrc @@ -3,4 +3,4 @@ skip = .git*,vendor,*-lock.yaml,*.lock,.codespellrc,*test.ts,*.jsonl,frame*.txt,*.snap,*.snap.new,*meriyah.umd.min.js check-hidden = true ignore-regex = ^\s*"image/\S+": ".*|\b(afterAll)\b -ignore-words-list = ratatui,ser,iTerm,iterm2,iterm,te,TE +ignore-words-list = ratatui,ser,iTerm,iterm2,iterm,te,TE,PASE,SEH diff --git a/.codex/skills/babysit-pr/SKILL.md b/.codex/skills/babysit-pr/SKILL.md index 4e44563c7b..4718f55b50 100644 --- a/.codex/skills/babysit-pr/SKILL.md +++ b/.codex/skills/babysit-pr/SKILL.md @@ -1,6 +1,6 @@ --- name: babysit-pr -description: Babysit a GitHub pull request after creation by continuously polling CI checks/workflow runs, new review comments, and mergeability state until the PR is ready to merge (or merged/closed). Diagnose failures, retry likely flaky failures up to 3 times, auto-fix/push branch-related issues when appropriate, and stop only when user help is required (for example CI infrastructure issues, exhausted flaky retries, or ambiguous/blocking situations). Use when the user asks Codex to monitor a PR, watch CI, handle review comments, or keep an eye on failures and feedback on an open PR. +description: Babysit a GitHub pull request after creation by continuously polling review comments, CI checks/workflow runs, and mergeability state until the PR is merged/closed or user help is required. Diagnose failures, retry likely flaky failures up to 3 times, auto-fix/push branch-related issues when appropriate, and keep watching open PRs so fresh review feedback is surfaced promptly. Use when the user asks Codex to monitor a PR, watch CI, handle review comments, or keep an eye on failures and feedback on an open PR. --- # PR Babysitter @@ -9,8 +9,8 @@ description: Babysit a GitHub pull request after creation by continuously pollin Babysit a PR persistently until one of these terminal outcomes occurs: - The PR is merged or closed. -- CI is successful, there are no unaddressed review comments surfaced by the watcher, required review approval is not blocking merge, and there are no potential merge conflicts (PR is mergeable / not reporting conflict risk). - A situation requires user help (for example CI infrastructure issues, repeated flaky failures after retry budget is exhausted, permission problems, or ambiguity that cannot be resolved safely). +- Optional handoff milestone: the PR is currently green + mergeable + review-clean. Treat this as a progress state, not a watcher stop, so late-arriving review comments are still surfaced promptly while the PR remains open. Do not stop merely because a single snapshot returns `idle` while checks are still pending. @@ -24,19 +24,20 @@ Accept any of the following: ## Core Workflow 1. When the user asks to "monitor"/"watch"/"babysit" a PR, start with the watcher's continuous mode (`--watch`) unless you are intentionally doing a one-shot diagnostic snapshot. -2. Run the watcher script to snapshot PR/CI/review state (or consume each streamed snapshot from `--watch`). +2. Run the watcher script to snapshot PR/review/CI state (or consume each streamed snapshot from `--watch`). 3. Inspect the `actions` list in the JSON response. 4. If `diagnose_ci_failure` is present, inspect failed run logs and classify the failure. 5. If the failure is likely caused by the current branch, patch code locally, commit, and push. 6. If `process_review_comment` is present, inspect surfaced review items and decide whether to address them. -7. If a review item is actionable and correct, patch code locally, commit, and push. -8. If the failure is likely flaky/unrelated and `retry_failed_checks` is present, rerun failed jobs with `--retry-failed-now`. -9. If both actionable review feedback and `retry_failed_checks` are present, prioritize review feedback first; a new commit will retrigger CI, so avoid rerunning flaky checks on the old SHA unless you intentionally defer the review change. -10. On every loop, verify mergeability / merge-conflict status (for example via `gh pr view`) in addition to CI and review state. -11. After any push or rerun action, immediately return to step 1 and continue polling on the updated SHA/state. -12. If you had been using `--watch` before pausing to patch/commit/push, relaunch `--watch` yourself in the same turn immediately after the push (do not wait for the user to re-invoke the skill). -13. Repeat polling until the PR is green + review-clean + mergeable, `stop_pr_closed` appears, or a user-help-required blocker is reached. -14. Maintain terminal/session ownership: while babysitting is active, keep consuming watcher output in the same turn; do not leave a detached `--watch` process running and then end the turn as if monitoring were complete. +7. If a review item is actionable and correct, patch code locally, commit, push, and then mark the associated review thread/comment as resolved once the fix is on GitHub. +8. If a review item from another author is non-actionable, already addressed, or not valid, post one reply on the comment/thread explaining that decision (for example answering the question or explaining why no change is needed). If the watcher later surfaces your own reply, treat that self-authored item as already handled and do not reply again. +9. If the failure is likely flaky/unrelated and `retry_failed_checks` is present, rerun failed jobs with `--retry-failed-now`. +10. If both actionable review feedback and `retry_failed_checks` are present, prioritize review feedback first; a new commit will retrigger CI, so avoid rerunning flaky checks on the old SHA unless you intentionally defer the review change. +11. On every loop, look for newly surfaced review feedback before acting on CI failures or mergeability state, then verify mergeability / merge-conflict status (for example via `gh pr view`) alongside CI. +12. After any push or rerun action, immediately return to step 1 and continue polling on the updated SHA/state. +13. If you had been using `--watch` before pausing to patch/commit/push, relaunch `--watch` yourself in the same turn immediately after the push (do not wait for the user to re-invoke the skill). +14. Repeat polling until `stop_pr_closed` appears or a user-help-required blocker is reached. A green + review-clean + mergeable PR is a progress milestone, not a reason to stop the watcher while the PR is still open. +15. Maintain terminal/session ownership: while babysitting is active, keep consuming watcher output in the same turn; do not leave a detached `--watch` process running and then end the turn as if monitoring were complete. ## Commands @@ -94,10 +95,11 @@ When you agree with a comment and it is actionable: 1. Patch code locally. 2. Commit with `codex: address PR review feedback (#)`. 3. Push to the PR head branch. -4. Resume watching on the new SHA immediately (do not stop after reporting the push). -5. If monitoring was running in `--watch` mode, restart `--watch` immediately after the push in the same turn; do not wait for the user to ask again. +4. After the push succeeds, mark the associated GitHub review thread/comment as resolved. +5. Resume watching on the new SHA immediately (do not stop after reporting the push). +6. If monitoring was running in `--watch` mode, restart `--watch` immediately after the push in the same turn; do not wait for the user to ask again. -If you disagree or the comment is non-actionable/already addressed, record it as handled by continuing the watcher loop (the script de-duplicates surfaced items via state after surfacing them). +If you disagree or the comment is non-actionable/already addressed, reply once directly on the GitHub comment/thread so the reviewer gets an explicit answer, then continue the watcher loop. If the watcher later surfaces your own reply because the authenticated operator is treated as a trusted review author, treat that self-authored item as already handled and do not reply again. If a code review comment/thread is already marked as resolved in GitHub, treat it as non-actionable and safely ignore it unless new unresolved follow-up feedback appears. ## Git Safety Rules @@ -124,13 +126,14 @@ Use this loop in a live Codex session: 3. First check whether the PR is now merged or otherwise closed; if so, report that terminal state and stop polling immediately. 4. Check CI summary, new review items, and mergeability/conflict status. 5. Diagnose CI failures and classify branch-related vs flaky/unrelated. -6. Process actionable review comments before flaky reruns when both are present; if a review fix requires a commit, push it and skip rerunning failed checks on the old SHA. -7. Retry failed checks only when `retry_failed_checks` is present and you are not about to replace the current SHA with a review/CI fix commit. -8. If you pushed a commit or triggered a rerun, report the action briefly and continue polling (do not stop). -9. After a review-fix push, proactively restart continuous monitoring (`--watch`) in the same turn unless a strict stop condition has already been reached. -10. If everything is passing, mergeable, not blocked on required review approval, and there are no unaddressed review items, report success and stop. -11. If blocked on a user-help-required issue (infra outage, exhausted flaky retries, unclear reviewer request, permissions), report the blocker and stop. -12. Otherwise sleep according to the polling cadence below and repeat. +6. For each surfaced review item from another author, either reply once with an explanation if it is non-actionable or patch/commit/push and then resolve it if it is actionable. If a later snapshot surfaces your own reply, treat it as informational and continue without responding again. +7. Process actionable review comments before flaky reruns when both are present; if a review fix requires a commit, push it and skip rerunning failed checks on the old SHA. +8. Retry failed checks only when `retry_failed_checks` is present and you are not about to replace the current SHA with a review/CI fix commit. +9. If you pushed a commit, resolved a review thread, replied to a review comment, or triggered a rerun, report the action briefly and continue polling (do not stop). +10. After a review-fix push, proactively restart continuous monitoring (`--watch`) in the same turn unless a strict stop condition has already been reached. +11. If everything is passing, mergeable, not blocked on required review approval, and there are no unaddressed review items, report that the PR is currently ready to merge but keep the watcher running so new review comments are surfaced quickly while the PR remains open. +12. If blocked on a user-help-required issue (infra outage, exhausted flaky retries, unclear reviewer request, permissions), report the blocker and stop. +13. Otherwise sleep according to the polling cadence below and repeat. When the user explicitly asks to monitor/watch/babysit a PR, prefer `--watch` so polling continues autonomously in one command. Use repeated `--once` snapshots only for debugging, local testing, or when the user explicitly asks for a one-shot check. Do not stop to ask the user whether to continue polling; continue autonomously until a strict stop condition is met or the user explicitly interrupts. @@ -138,19 +141,18 @@ Do not hand control back to the user after a review-fix push just because a new If a `--watch` process is still running and no strict stop condition has been reached, the babysitting task is still in progress; keep streaming/consuming watcher output instead of ending the turn. ## Polling Cadence -Use adaptive polling and continue monitoring even after CI turns green: +Keep review polling aggressive and continue monitoring even after CI turns green: - While CI is not green (pending/running/queued or failing): poll every 1 minute. -- After CI turns green: start at every 1 minute, then back off exponentially when there is no change (for example 1m, 2m, 4m, 8m, 16m, 32m), capping at every 1 hour. -- Reset the green-state polling interval back to 1 minute whenever anything changes (new commit/SHA, check status changes, new review comments, mergeability changes, review decision changes). -- If CI stops being green again (new commit, rerun, or regression): return to 1-minute polling. +- After CI turns green: keep polling at the base cadence while the PR remains open so newly posted review comments are surfaced promptly instead of waiting on a long green-state backoff. +- Reset the cadence immediately whenever anything changes (new commit/SHA, check status changes, new review comments, mergeability changes, review decision changes). +- If CI stops being green again (new commit, rerun, or regression): stay on the base polling cadence. - If any poll shows the PR is merged or otherwise closed: stop polling immediately and report the terminal state. ## Stop Conditions (Strict) Stop only when one of the following is true: - PR merged or closed (stop as soon as a poll/snapshot confirms this). -- PR is ready to merge: CI succeeded, no surfaced unaddressed review comments, not blocked on required review approval, and no merge conflict risk. - User intervention is required and Codex cannot safely proceed alone. Keep polling when: @@ -159,14 +161,14 @@ Keep polling when: - CI is still running/queued. - Review state is quiet but CI is not terminal. - CI is green but mergeability is unknown/pending. -- CI is green and mergeable, but the PR is still open and you are waiting for possible new review comments or merge-conflict changes per the green-state cadence. -- The PR is green but blocked on review approval (`REVIEW_REQUIRED` / similar); continue polling on the green-state cadence and surface any new review comments without asking for confirmation to keep watching. +- CI is green and mergeable, but the PR is still open and you are waiting for possible new review comments or merge-conflict changes. +- The PR is green but blocked on review approval (`REVIEW_REQUIRED` / similar); continue polling at the base cadence and surface any new review comments without asking for confirmation to keep watching. ## Output Expectations Provide concise progress updates while monitoring and a final summary that includes: - During long unchanged monitoring periods, avoid emitting a full update on every poll; summarize only status changes plus occasional heartbeat updates. -- Treat push confirmations, intermediate CI snapshots, and review-action updates as progress updates only; do not emit the final summary or end the babysitting session unless a strict stop condition is met. +- Treat push confirmations, intermediate CI snapshots, ready-to-merge snapshots, and review-action updates as progress updates only; do not emit the final summary or end the babysitting session unless a strict stop condition is met. - A user request to "monitor" is not satisfied by a couple of sample polls; remain in the loop until a strict stop condition or an explicit user interruption. - A review-fix commit + push is not a completion event; immediately resume live monitoring (`--watch`) in the same turn and continue reporting progress updates. - When CI first transitions to all green for the current SHA, emit a one-time celebratory progress update (do not repeat it on every green poll). Preferred style: `🚀 CI is all green! 33/33 passed. Still on watch for review approval.` diff --git a/.codex/skills/babysit-pr/agents/openai.yaml b/.codex/skills/babysit-pr/agents/openai.yaml index 2faca770e0..b68a7287a2 100644 --- a/.codex/skills/babysit-pr/agents/openai.yaml +++ b/.codex/skills/babysit-pr/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "PR Babysitter" - short_description: "Watch PR CI, reviews, and merge conflicts" - default_prompt: "Babysit the current PR: monitor CI, reviewer comments, and merge-conflict status (prefer the watcher’s --watch mode for live monitoring); fix valid issues, push updates, and rerun flaky failures up to 3 times. Keep exactly one watcher session active for the PR (do not leave duplicate --watch terminals running). If you pause monitoring to patch review/CI feedback, restart --watch yourself immediately after the push in the same turn. If a watcher is still running and no strict stop condition has been reached, the task is still in progress: keep consuming watcher output and sending progress updates instead of ending the turn. Continue polling autonomously after any push/rerun until a strict terminal stop condition is reached or the user interrupts." + short_description: "Watch PR review comments, CI, and merge conflicts" + default_prompt: "Babysit the current PR: monitor reviewer comments, CI, and merge-conflict status (prefer the watcher’s --watch mode for live monitoring); surface new review feedback before acting on CI or mergeability work, fix valid issues, push updates, and rerun flaky failures up to 3 times. Keep exactly one watcher session active for the PR (do not leave duplicate --watch terminals running). If you pause monitoring to patch review/CI feedback, restart --watch yourself immediately after the push in the same turn. If a watcher is still running and no strict stop condition has been reached, the task is still in progress: keep consuming watcher output and sending progress updates instead of ending the turn. Do not treat a green + mergeable PR as a terminal stop while it is still open; continue polling autonomously after any push/rerun so newly posted review comments are surfaced until a strict terminal stop condition is reached or the user interrupts." diff --git a/.codex/skills/babysit-pr/scripts/gh_pr_watch.py b/.codex/skills/babysit-pr/scripts/gh_pr_watch.py index 80aefd58d9..2650770b2a 100755 --- a/.codex/skills/babysit-pr/scripts/gh_pr_watch.py +++ b/.codex/skills/babysit-pr/scripts/gh_pr_watch.py @@ -45,7 +45,6 @@ MERGE_CONFLICT_OR_BLOCKING_STATES = { "DRAFT", "UNKNOWN", } -GREEN_STATE_MAX_POLL_SECONDS = 60 * 60 class GhCommandError(RuntimeError): @@ -578,7 +577,7 @@ def recommend_actions(pr, checks_summary, failed_runs, new_review_items, retries return unique_actions(actions) if is_pr_ready_to_merge(pr, checks_summary, new_review_items): - actions.append("stop_ready_to_merge") + actions.append("ready_to_merge") return unique_actions(actions) if new_review_items: @@ -606,12 +605,6 @@ def collect_snapshot(args): if not state.get("started_at"): state["started_at"] = int(time.time()) - # `gh pr checks -R ` requires an explicit PR/branch/url argument. - # After resolving `--pr auto`, reuse the concrete PR number. - checks = get_pr_checks(str(pr["number"]), repo=pr["repo"]) - checks_summary = summarize_checks(checks) - workflow_runs = get_workflow_runs_for_sha(pr["repo"], pr["head_sha"]) - failed_runs = failed_runs_from_workflow_runs(workflow_runs, pr["head_sha"]) authenticated_login = get_authenticated_login() new_review_items = fetch_new_review_items( pr, @@ -619,6 +612,15 @@ def collect_snapshot(args): fresh_state=fresh_state, authenticated_login=authenticated_login, ) + # Surface review feedback before drilling into CI and mergeability details. + # That keeps the babysitter responsive to new comments even when other + # actions are also available. + # `gh pr checks -R ` requires an explicit PR/branch/url argument. + # After resolving `--pr auto`, reuse the concrete PR number. + checks = get_pr_checks(str(pr["number"]), repo=pr["repo"]) + checks_summary = summarize_checks(checks) + workflow_runs = get_workflow_runs_for_sha(pr["repo"], pr["head_sha"]) + failed_runs = failed_runs_from_workflow_runs(workflow_runs, pr["head_sha"]) retries_used = current_retry_count(state, pr["head_sha"]) actions = recommend_actions( @@ -761,7 +763,6 @@ def run_watch(args): if ( "stop_pr_closed" in actions or "stop_exhausted_retries" in actions - or "stop_ready_to_merge" in actions ): print_event("stop", {"actions": snapshot.get("actions"), "pr": snapshot.get("pr")}) return 0 @@ -769,13 +770,13 @@ def run_watch(args): current_change_key = snapshot_change_key(snapshot) changed = current_change_key != last_change_key green = is_ci_green(snapshot) + pr = snapshot.get("pr") or {} + pr_open = not bool(pr.get("closed")) and not bool(pr.get("merged")) - if not green: + if not green or pr_open: poll_seconds = args.poll_seconds elif changed or last_change_key is None: poll_seconds = args.poll_seconds - else: - poll_seconds = min(poll_seconds * 2, GREEN_STATE_MAX_POLL_SECONDS) last_change_key = current_change_key time.sleep(poll_seconds) diff --git a/.codex/skills/babysit-pr/scripts/test_gh_pr_watch.py b/.codex/skills/babysit-pr/scripts/test_gh_pr_watch.py new file mode 100644 index 0000000000..c6a5d25682 --- /dev/null +++ b/.codex/skills/babysit-pr/scripts/test_gh_pr_watch.py @@ -0,0 +1,155 @@ +import argparse +import importlib.util +from pathlib import Path + +import pytest + + +MODULE_PATH = Path(__file__).with_name("gh_pr_watch.py") +MODULE_SPEC = importlib.util.spec_from_file_location("gh_pr_watch", MODULE_PATH) +gh_pr_watch = importlib.util.module_from_spec(MODULE_SPEC) +assert MODULE_SPEC.loader is not None +MODULE_SPEC.loader.exec_module(gh_pr_watch) + + +def sample_pr(): + return { + "number": 123, + "url": "https://github.com/openai/codex/pull/123", + "repo": "openai/codex", + "head_sha": "abc123", + "head_branch": "feature", + "state": "OPEN", + "merged": False, + "closed": False, + "mergeable": "MERGEABLE", + "merge_state_status": "CLEAN", + "review_decision": "", + } + + +def sample_checks(**overrides): + checks = { + "pending_count": 0, + "failed_count": 0, + "passed_count": 12, + "all_terminal": True, + } + checks.update(overrides) + return checks + + +def test_collect_snapshot_fetches_review_items_before_ci(monkeypatch, tmp_path): + call_order = [] + pr = sample_pr() + + monkeypatch.setattr(gh_pr_watch, "resolve_pr", lambda *args, **kwargs: pr) + monkeypatch.setattr(gh_pr_watch, "load_state", lambda path: ({}, True)) + monkeypatch.setattr( + gh_pr_watch, + "get_authenticated_login", + lambda: call_order.append("auth") or "octocat", + ) + monkeypatch.setattr( + gh_pr_watch, + "fetch_new_review_items", + lambda *args, **kwargs: call_order.append("review") or [], + ) + monkeypatch.setattr( + gh_pr_watch, + "get_pr_checks", + lambda *args, **kwargs: call_order.append("checks") or [], + ) + monkeypatch.setattr( + gh_pr_watch, + "summarize_checks", + lambda checks: call_order.append("summarize") or sample_checks(), + ) + monkeypatch.setattr( + gh_pr_watch, + "get_workflow_runs_for_sha", + lambda *args, **kwargs: call_order.append("workflow") or [], + ) + monkeypatch.setattr( + gh_pr_watch, + "failed_runs_from_workflow_runs", + lambda *args, **kwargs: call_order.append("failed_runs") or [], + ) + monkeypatch.setattr( + gh_pr_watch, + "recommend_actions", + lambda *args, **kwargs: call_order.append("recommend") or ["idle"], + ) + monkeypatch.setattr(gh_pr_watch, "save_state", lambda *args, **kwargs: None) + + args = argparse.Namespace( + pr="123", + repo=None, + state_file=str(tmp_path / "watcher-state.json"), + max_flaky_retries=3, + ) + + gh_pr_watch.collect_snapshot(args) + + assert call_order.index("review") < call_order.index("checks") + assert call_order.index("review") < call_order.index("workflow") + + +def test_recommend_actions_prioritizes_review_comments(): + actions = gh_pr_watch.recommend_actions( + sample_pr(), + sample_checks(failed_count=1), + [{"run_id": 99}], + [{"kind": "review_comment", "id": "1"}], + 0, + 3, + ) + + assert actions == [ + "process_review_comment", + "diagnose_ci_failure", + "retry_failed_checks", + ] + + +def test_run_watch_keeps_polling_open_ready_to_merge_pr(monkeypatch): + sleeps = [] + events = [] + snapshot = { + "pr": sample_pr(), + "checks": sample_checks(), + "failed_runs": [], + "new_review_items": [], + "actions": ["ready_to_merge"], + "retry_state": { + "current_sha_retries_used": 0, + "max_flaky_retries": 3, + }, + } + + monkeypatch.setattr( + gh_pr_watch, + "collect_snapshot", + lambda args: (snapshot, Path("/tmp/codex-babysit-pr-state.json")), + ) + monkeypatch.setattr( + gh_pr_watch, + "print_event", + lambda event, payload: events.append((event, payload)), + ) + + class StopWatch(Exception): + pass + + def fake_sleep(seconds): + sleeps.append(seconds) + if len(sleeps) >= 2: + raise StopWatch + + monkeypatch.setattr(gh_pr_watch.time, "sleep", fake_sleep) + + with pytest.raises(StopWatch): + gh_pr_watch.run_watch(argparse.Namespace(poll_seconds=30)) + + assert sleeps == [30, 30] + assert [event for event, _ in events] == ["snapshot", "snapshot"] diff --git a/.github/actions/setup-bazel-ci/action.yml b/.github/actions/setup-bazel-ci/action.yml new file mode 100644 index 0000000000..a7e3b322c2 --- /dev/null +++ b/.github/actions/setup-bazel-ci/action.yml @@ -0,0 +1,128 @@ +name: setup-bazel-ci +description: Prepare a Bazel CI runner with shared caches and optional test prerequisites. +inputs: + target: + description: Target triple used for cache namespacing. + required: true + install-test-prereqs: + description: Install Node.js and DotSlash for Bazel-backed test jobs. + required: false + default: "false" +outputs: + repository-cache-path: + description: Filesystem path used for the Bazel repository cache. + value: ${{ steps.configure_bazel_repository_cache.outputs.repository-cache-path }} + +runs: + using: composite + steps: + - name: Set up Node.js for js_repl tests + if: inputs.install-test-prereqs == 'true' + uses: actions/setup-node@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 + if: inputs.install-test-prereqs == 'true' + uses: facebook/install-dotslash@v2 + + - name: Make DotSlash available in PATH (Unix) + if: inputs.install-test-prereqs == 'true' && runner.os != 'Windows' + shell: bash + run: cp "$(which dotslash)" /usr/local/bin + + - name: Make DotSlash available in PATH (Windows) + if: inputs.install-test-prereqs == 'true' && runner.os == 'Windows' + shell: pwsh + run: Copy-Item (Get-Command dotslash).Source -Destination "$env:LOCALAPPDATA\Microsoft\WindowsApps\dotslash.exe" + + - name: Set up Bazel + uses: bazelbuild/setup-bazelisk@v3 + + - name: Configure Bazel repository cache + id: configure_bazel_repository_cache + shell: pwsh + run: | + # Keep the repository cache under HOME on all runners. Windows `D:\a` + # cache paths match `.bazelrc`, but `actions/cache/restore` currently + # returns HTTP 400 for that path in the Windows clippy job. + $repositoryCachePath = Join-Path $HOME '.cache/bazel-repo-cache' + "repository-cache-path=$repositoryCachePath" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + "BAZEL_REPOSITORY_CACHE=$repositoryCachePath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Configure Bazel output root (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + # Use the shortest available drive to reduce argv/path length issues, + # but avoid the drive root because some Windows test launchers mis-handle + # MANIFEST paths there. + $hasDDrive = Test-Path 'D:\' + $bazelOutputUserRoot = if ($hasDDrive) { 'D:\b' } else { 'C:\b' } + $repoContentsCache = Join-Path $env:RUNNER_TEMP "bazel-repo-contents-cache-$env:GITHUB_RUN_ID-$env:GITHUB_JOB" + "BAZEL_OUTPUT_USER_ROOT=$bazelOutputUserRoot" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "BAZEL_REPO_CONTENTS_CACHE=$repoContentsCache" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Expose MSVC SDK environment (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + # Bazel exec-side Rust build scripts do not reliably inherit the MSVC developer + # shell on GitHub-hosted Windows runners, so discover the latest VS install and + # ask `VsDevCmd.bat` to materialize the x64/x64 compiler + SDK environment. + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + if (-not (Test-Path $vswhere)) { + throw "vswhere.exe not found" + } + + $installPath = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath 2>$null + if (-not $installPath) { + throw "Could not locate a Visual Studio installation with VC tools" + } + + $vsDevCmd = Join-Path $installPath 'Common7\Tools\VsDevCmd.bat' + if (-not (Test-Path $vsDevCmd)) { + throw "VsDevCmd.bat not found at $vsDevCmd" + } + + # Keep the export surface explicit: these are the paths and SDK roots that the + # MSVC toolchain probes need later when Bazel runs Windows exec-platform build + # scripts such as `aws-lc-sys`. + $varsToExport = @( + 'INCLUDE', + 'LIB', + 'LIBPATH', + 'PATH', + 'UCRTVersion', + 'UniversalCRTSdkDir', + 'VCINSTALLDIR', + 'VCToolsInstallDir', + 'WindowsLibPath', + 'WindowsSdkBinPath', + 'WindowsSdkDir', + 'WindowsSDKLibVersion', + 'WindowsSDKVersion' + ) + + # `VsDevCmd.bat` is a batch file, so invoke it under `cmd.exe`, suppress its + # banner, then dump the resulting environment with `set`. Re-export only the + # approved keys into `GITHUB_ENV` so later steps inherit the same MSVC context. + $envLines = & cmd.exe /c ('"{0}" -no_logo -arch=x64 -host_arch=x64 >nul && set' -f $vsDevCmd) + foreach ($line in $envLines) { + if ($line -notmatch '^(.*?)=(.*)$') { + continue + } + + $name = $matches[1] + $value = $matches[2] + if ($varsToExport -contains $name) { + "$name=$value" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + } + } + + - name: Enable Git long paths (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: git config --global core.longpaths true diff --git a/.github/scripts/run-argument-comment-lint-bazel.sh b/.github/scripts/run-argument-comment-lint-bazel.sh new file mode 100755 index 0000000000..e2f494d620 --- /dev/null +++ b/.github/scripts/run-argument-comment-lint-bazel.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ci_config=ci-linux +case "${RUNNER_OS:-}" in + macOS) + ci_config=ci-macos + ;; + Windows) + ci_config=ci-windows + ;; +esac + +bazel_lint_args=("$@") +if [[ "${RUNNER_OS:-}" == "Windows" ]]; then + has_host_platform_override=0 + for arg in "${bazel_lint_args[@]}"; do + if [[ "$arg" == --host_platform=* ]]; then + has_host_platform_override=1 + break + fi + done + + if [[ $has_host_platform_override -eq 0 ]]; then + # The nightly Windows lint toolchain is registered with an MSVC exec + # platform even though the lint target platform stays on `windows-gnullvm`. + # Override the host platform here so the exec-side helper binaries actually + # match the registered toolchain set. + bazel_lint_args+=("--host_platform=//:local_windows_msvc") + fi + + # Native Windows lint runs need exec-side Rust helper binaries and proc-macros + # to use rust-lld instead of the C++ linker path. The default `none` + # preference resolves to `cc` when a cc_toolchain is present, which currently + # routes these exec actions through clang++ with an argument shape it cannot + # consume. + bazel_lint_args+=("--@rules_rust//rust/settings:toolchain_linker_preference=rust") + + # Some Rust top-level targets are still intentionally incompatible with the + # local Windows MSVC exec platform. Skip those explicit targets so the native + # lint aspect can run across the compatible crate graph instead of failing the + # whole build after analysis. + bazel_lint_args+=("--skip_incompatible_explicit_targets") +fi + +bazel_startup_args=() +if [[ -n "${BAZEL_OUTPUT_USER_ROOT:-}" ]]; then + bazel_startup_args+=("--output_user_root=${BAZEL_OUTPUT_USER_ROOT}") +fi + +run_bazel() { + if [[ "${RUNNER_OS:-}" == "Windows" ]]; then + MSYS2_ARG_CONV_EXCL='*' bazel "$@" + return + fi + + bazel "$@" +} + +run_bazel_with_startup_args() { + if [[ ${#bazel_startup_args[@]} -gt 0 ]]; then + run_bazel "${bazel_startup_args[@]}" "$@" + return + fi + + run_bazel "$@" +} + +read_query_labels() { + local query="$1" + local query_stdout + local query_stderr + query_stdout="$(mktemp)" + query_stderr="$(mktemp)" + + if ! run_bazel_with_startup_args \ + --noexperimental_remote_repo_contents_cache \ + query \ + --keep_going \ + --output=label \ + "$query" >"$query_stdout" 2>"$query_stderr"; then + cat "$query_stderr" >&2 + rm -f "$query_stdout" "$query_stderr" + exit 1 + fi + + cat "$query_stdout" + rm -f "$query_stdout" "$query_stderr" +} + +final_build_targets=(//codex-rs/...) +if [[ "${RUNNER_OS:-}" == "Windows" ]]; then + # Bazel's local Windows platform currently lacks a default test toolchain for + # `rust_test`, so target the concrete Rust crate rules directly. The lint + # aspect still walks their crate graph, which preserves incremental reuse for + # non-test code while avoiding non-Rust wrapper targets such as platform_data. + final_build_targets=() + while IFS= read -r label; do + [[ -n "$label" ]] || continue + final_build_targets+=("$label") + done < <(read_query_labels 'kind("rust_(library|binary|proc_macro) rule", //codex-rs/...)') + + if [[ ${#final_build_targets[@]} -eq 0 ]]; then + echo "Failed to discover Windows Bazel lint targets." >&2 + exit 1 + fi +fi + +./.github/scripts/run-bazel-ci.sh \ + -- \ + build \ + "${bazel_lint_args[@]}" \ + -- \ + "${final_build_targets[@]}" diff --git a/.github/scripts/run-bazel-ci.sh b/.github/scripts/run-bazel-ci.sh new file mode 100755 index 0000000000..9c95fda157 --- /dev/null +++ b/.github/scripts/run-bazel-ci.sh @@ -0,0 +1,278 @@ +#!/usr/bin/env bash + +set -euo pipefail + +print_failed_bazel_test_logs=0 +use_node_test_env=0 +remote_download_toplevel=0 +windows_msvc_host_platform=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --print-failed-test-logs) + print_failed_bazel_test_logs=1 + shift + ;; + --use-node-test-env) + use_node_test_env=1 + shift + ;; + --remote-download-toplevel) + remote_download_toplevel=1 + shift + ;; + --windows-msvc-host-platform) + windows_msvc_host_platform=1 + shift + ;; + --) + shift + break + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +if [[ $# -eq 0 ]]; then + echo "Usage: $0 [--print-failed-test-logs] [--use-node-test-env] [--remote-download-toplevel] [--windows-msvc-host-platform] -- -- " >&2 + exit 1 +fi + +bazel_startup_args=() +if [[ -n "${BAZEL_OUTPUT_USER_ROOT:-}" ]]; then + bazel_startup_args+=("--output_user_root=${BAZEL_OUTPUT_USER_ROOT}") +fi + +run_bazel() { + if [[ "${RUNNER_OS:-}" == "Windows" ]]; then + MSYS2_ARG_CONV_EXCL='*' bazel "$@" + return + fi + + bazel "$@" +} + +ci_config=ci-linux +case "${RUNNER_OS:-}" in + macOS) + ci_config=ci-macos + ;; + Windows) + ci_config=ci-windows + ;; +esac + +print_bazel_test_log_tails() { + local console_log="$1" + local testlogs_dir + local -a bazel_info_cmd=(bazel) + + if (( ${#bazel_startup_args[@]} > 0 )); then + bazel_info_cmd+=("${bazel_startup_args[@]}") + fi + + testlogs_dir="$(run_bazel "${bazel_info_cmd[@]:1}" info bazel-testlogs 2>/dev/null || echo bazel-testlogs)" + + local failed_targets=() + while IFS= read -r target; do + failed_targets+=("$target") + done < <( + grep -E '^FAIL: //' "$console_log" \ + | sed -E 's#^FAIL: (//[^ ]+).*#\1#' \ + | sort -u + ) + + if [[ ${#failed_targets[@]} -eq 0 ]]; then + echo "No failed Bazel test targets were found in console output." + return + fi + + for target in "${failed_targets[@]}"; do + local rel_path="${target#//}" + rel_path="${rel_path/:/\/}" + local test_log="${testlogs_dir}/${rel_path}/test.log" + + echo "::group::Bazel test log tail for ${target}" + if [[ -f "$test_log" ]]; then + tail -n 200 "$test_log" + else + echo "Missing test log: $test_log" + fi + echo "::endgroup::" + done +} + +bazel_args=() +bazel_targets=() +found_target_separator=0 +for arg in "$@"; do + if [[ "$arg" == "--" && $found_target_separator -eq 0 ]]; then + found_target_separator=1 + continue + fi + + if [[ $found_target_separator -eq 0 ]]; then + bazel_args+=("$arg") + else + bazel_targets+=("$arg") + fi +done + +if [[ ${#bazel_args[@]} -eq 0 || ${#bazel_targets[@]} -eq 0 ]]; then + echo "Expected Bazel args and targets separated by --" >&2 + 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 + for arg in "${bazel_args[@]}"; do + if [[ "$arg" == --host_platform=* ]]; then + has_host_platform_override=1 + break + fi + done + + if [[ $has_host_platform_override -eq 0 ]]; then + # Keep Windows Bazel targets on `windows-gnullvm` for cfg coverage, but opt + # specific jobs into an MSVC exec platform when they need helper binaries + # like Rust test wrappers and V8 generators to resolve a compatible host + # toolchain. + post_config_bazel_args+=("--host_platform=//:local_windows_msvc") + fi +fi + +if [[ $remote_download_toplevel -eq 1 ]]; then + # Override the CI config's remote_download_minimal setting when callers need + # the built artifact to exist on disk after the command completes. + post_config_bazel_args+=(--remote_download_toplevel) +fi + +if [[ -n "${BAZEL_REPO_CONTENTS_CACHE:-}" ]]; then + # Windows self-hosted runners can run multiple Bazel jobs concurrently. Give + # each job its own repo contents cache so they do not fight over the shared + # path configured in `ci-windows`. + post_config_bazel_args+=("--repo_contents_cache=${BAZEL_REPO_CONTENTS_CACHE}") +fi + +if [[ -n "${BAZEL_REPOSITORY_CACHE:-}" ]]; then + post_config_bazel_args+=("--repository_cache=${BAZEL_REPOSITORY_CACHE}") +fi + +if [[ -n "${CODEX_BAZEL_EXECUTION_LOG_COMPACT_DIR:-}" ]]; then + post_config_bazel_args+=( + "--execution_log_compact_file=${CODEX_BAZEL_EXECUTION_LOG_COMPACT_DIR}/execution-log-${bazel_args[0]}-${GITHUB_JOB:-local}-$$.zst" + ) +fi + +if [[ "${RUNNER_OS:-}" == "Windows" ]]; then + windows_action_env_vars=( + INCLUDE + LIB + LIBPATH + PATH + UCRTVersion + UniversalCRTSdkDir + VCINSTALLDIR + VCToolsInstallDir + WindowsLibPath + WindowsSdkBinPath + WindowsSdkDir + WindowsSDKLibVersion + WindowsSDKVersion + ) + + for env_var in "${windows_action_env_vars[@]}"; do + if [[ -n "${!env_var:-}" ]]; then + post_config_bazel_args+=("--action_env=${env_var}" "--host_action_env=${env_var}") + fi + done +fi + +bazel_console_log="$(mktemp)" +trap 'rm -f "$bazel_console_log"' EXIT + +bazel_cmd=(bazel) +if (( ${#bazel_startup_args[@]} > 0 )); then + bazel_cmd+=("${bazel_startup_args[@]}") +fi + +if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then + echo "BuildBuddy API key is available; using remote Bazel configuration." + # Work around Bazel 9 remote repo contents cache / overlay materialization failures + # seen in CI (for example "is not a symlink" or permission errors while + # materializing external repos such as rules_perl). We still use BuildBuddy for + # remote execution/cache; this only disables the startup-level repo contents cache. + bazel_run_args=( + "${bazel_args[@]}" + "--config=${ci_config}" + "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" + ) + if (( ${#post_config_bazel_args[@]} > 0 )); then + bazel_run_args+=("${post_config_bazel_args[@]}") + fi + set +e + run_bazel "${bazel_cmd[@]:1}" \ + --noexperimental_remote_repo_contents_cache \ + "${bazel_run_args[@]}" \ + -- \ + "${bazel_targets[@]}" \ + 2>&1 | tee "$bazel_console_log" + bazel_status=${PIPESTATUS[0]} + set -e +else + echo "BuildBuddy API key is not available; using local Bazel configuration." + # Keep fork/community PRs on Bazel but disable remote services that are + # configured in .bazelrc and require auth. + # + # Flag docs: + # - Command-line reference: https://bazel.build/reference/command-line-reference + # - Remote caching overview: https://bazel.build/remote/caching + # - Remote execution overview: https://bazel.build/remote/rbe + # - Build Event Protocol overview: https://bazel.build/remote/bep + # + # --noexperimental_remote_repo_contents_cache: + # disable remote repo contents cache enabled in .bazelrc startup options. + # https://bazel.build/reference/command-line-reference#startup_options-flag--experimental_remote_repo_contents_cache + # --remote_cache= and --remote_executor=: + # clear remote cache/execution endpoints configured in .bazelrc. + # https://bazel.build/reference/command-line-reference#common_options-flag--remote_cache + # https://bazel.build/reference/command-line-reference#common_options-flag--remote_executor + bazel_run_args=( + "${bazel_args[@]}" + --remote_cache= + --remote_executor= + ) + if (( ${#post_config_bazel_args[@]} > 0 )); then + bazel_run_args+=("${post_config_bazel_args[@]}") + fi + set +e + run_bazel "${bazel_cmd[@]:1}" \ + --noexperimental_remote_repo_contents_cache \ + "${bazel_run_args[@]}" \ + -- \ + "${bazel_targets[@]}" \ + 2>&1 | tee "$bazel_console_log" + bazel_status=${PIPESTATUS[0]} + set -e +fi + +if [[ ${bazel_status:-0} -ne 0 ]]; then + if [[ $print_failed_bazel_test_logs -eq 1 ]]; then + print_bazel_test_log_tails "$bazel_console_log" + fi + exit "$bazel_status" +fi diff --git a/.github/scripts/verify_bazel_clippy_lints.py b/.github/scripts/verify_bazel_clippy_lints.py new file mode 100644 index 0000000000..6b27973972 --- /dev/null +++ b/.github/scripts/verify_bazel_clippy_lints.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import re +import sys +import tomllib +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_CARGO_TOML = ROOT / "codex-rs" / "Cargo.toml" +DEFAULT_BAZELRC = ROOT / ".bazelrc" +BAZEL_CLIPPY_FLAG_PREFIX = "build:clippy --@rules_rust//rust/settings:clippy_flag=" +BAZEL_SPECIAL_FLAGS = {"-Dwarnings"} +VALID_LEVELS = {"allow", "warn", "deny", "forbid"} +LONG_FLAG_RE = re.compile( + r"^--(?Pallow|warn|deny|forbid)=clippy::(?P[a-z0-9_]+)$" +) +SHORT_FLAG_RE = re.compile(r"^-(?P[AWDF])clippy::(?P[a-z0-9_]+)$") +SHORT_LEVEL_NAMES = { + "A": "allow", + "W": "warn", + "D": "deny", + "F": "forbid", +} + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Verify that Bazel clippy flags in .bazelrc stay in sync with " + "codex-rs/Cargo.toml [workspace.lints.clippy]." + ) + ) + parser.add_argument( + "--cargo-toml", + type=Path, + default=DEFAULT_CARGO_TOML, + help="Path to the workspace Cargo.toml to inspect.", + ) + parser.add_argument( + "--bazelrc", + type=Path, + default=DEFAULT_BAZELRC, + help="Path to the .bazelrc file to inspect.", + ) + args = parser.parse_args() + + cargo_toml = args.cargo_toml.resolve() + bazelrc = args.bazelrc.resolve() + + cargo_lints = load_workspace_clippy_lints(cargo_toml) + bazel_lints = load_bazel_clippy_lints(bazelrc) + + missing = sorted(cargo_lints.keys() - bazel_lints.keys()) + extra = sorted(bazel_lints.keys() - cargo_lints.keys()) + mismatched = sorted( + lint + for lint in cargo_lints.keys() & bazel_lints.keys() + if cargo_lints[lint] != bazel_lints[lint] + ) + + if missing or extra or mismatched: + print_sync_error( + cargo_toml=cargo_toml, + bazelrc=bazelrc, + cargo_lints=cargo_lints, + bazel_lints=bazel_lints, + missing=missing, + extra=extra, + mismatched=mismatched, + ) + return 1 + + print( + "Bazel clippy flags in " + f"{display_path(bazelrc)} match " + f"{display_path(cargo_toml)} [workspace.lints.clippy]." + ) + return 0 + + +def load_workspace_clippy_lints(cargo_toml: Path) -> dict[str, str]: + workspace = tomllib.loads(cargo_toml.read_text())["workspace"] + clippy_lints = workspace["lints"]["clippy"] + parsed: dict[str, str] = {} + for lint, level in clippy_lints.items(): + if not isinstance(level, str): + raise SystemExit( + f"expected string lint level for clippy::{lint} in {cargo_toml}, got {level!r}" + ) + normalized = level.strip().lower() + if normalized not in VALID_LEVELS: + raise SystemExit( + f"unsupported lint level {level!r} for clippy::{lint} in {cargo_toml}" + ) + parsed[lint] = normalized + return parsed + + +def load_bazel_clippy_lints(bazelrc: Path) -> dict[str, str]: + parsed: dict[str, str] = {} + line_numbers: dict[str, int] = {} + + for lineno, line in enumerate(bazelrc.read_text().splitlines(), start=1): + if not line.startswith(BAZEL_CLIPPY_FLAG_PREFIX): + continue + + flag = line.removeprefix(BAZEL_CLIPPY_FLAG_PREFIX).strip() + if flag in BAZEL_SPECIAL_FLAGS: + continue + + parsed_flag = parse_bazel_lint_flag(flag) + if parsed_flag is None: + continue + + lint, level = parsed_flag + if lint in parsed: + raise SystemExit( + f"duplicate Bazel clippy entry for clippy::{lint} at " + f"{bazelrc}:{line_numbers[lint]} and {bazelrc}:{lineno}" + ) + parsed[lint] = level + line_numbers[lint] = lineno + + return parsed + + +def parse_bazel_lint_flag(flag: str) -> tuple[str, str] | None: + long_match = LONG_FLAG_RE.match(flag) + if long_match: + return long_match["lint"], long_match["level"] + + short_match = SHORT_FLAG_RE.match(flag) + if short_match: + return short_match["lint"], SHORT_LEVEL_NAMES[short_match["level"]] + + return None + + +def print_sync_error( + *, + cargo_toml: Path, + bazelrc: Path, + cargo_lints: dict[str, str], + bazel_lints: dict[str, str], + missing: list[str], + extra: list[str], + mismatched: list[str], +) -> None: + cargo_toml_display = display_path(cargo_toml) + bazelrc_display = display_path(bazelrc) + example_manifest = find_workspace_lints_example_manifest() + + print( + "ERROR: Bazel clippy flags are out of sync with Cargo workspace clippy lints.", + file=sys.stderr, + ) + print(file=sys.stderr) + print( + f"Cargo defines the source of truth in {cargo_toml_display} " + "[workspace.lints.clippy].", + file=sys.stderr, + ) + if example_manifest is not None: + print( + "Cargo applies those lint levels to member crates that opt into " + f"`[lints] workspace = true`, for example {example_manifest}.", + file=sys.stderr, + ) + print( + "Bazel clippy does not ingest Cargo lint levels automatically, and " + "`clippy.toml` can configure lint behavior but cannot set allow/warn/deny/forbid.", + file=sys.stderr, + ) + print( + f"Update {bazelrc_display} so its `build:clippy` " + "`clippy_flag` entries match Cargo.", + file=sys.stderr, + ) + + if missing: + print(file=sys.stderr) + print("Missing Bazel entries:", file=sys.stderr) + for lint in missing: + print(f" {render_bazelrc_line(lint, cargo_lints[lint])}", file=sys.stderr) + + if mismatched: + print(file=sys.stderr) + print("Mismatched lint levels:", file=sys.stderr) + for lint in mismatched: + cargo_level = cargo_lints[lint] + bazel_level = bazel_lints[lint] + print( + f" clippy::{lint}: Cargo has {cargo_level}, Bazel has {bazel_level}", + file=sys.stderr, + ) + print( + f" expected: {render_bazelrc_line(lint, cargo_level)}", + file=sys.stderr, + ) + + if extra: + print(file=sys.stderr) + print("Extra Bazel entries with no Cargo counterpart:", file=sys.stderr) + for lint in extra: + print(f" {render_bazelrc_line(lint, bazel_lints[lint])}", file=sys.stderr) + + +def render_bazelrc_line(lint: str, level: str) -> str: + return f"{BAZEL_CLIPPY_FLAG_PREFIX}--{level}=clippy::{lint}" + + +def display_path(path: Path) -> str: + try: + return str(path.relative_to(ROOT)) + except ValueError: + return str(path) + + +def find_workspace_lints_example_manifest() -> str | None: + for cargo_toml in sorted((ROOT / "codex-rs").glob("**/Cargo.toml")): + if cargo_toml == DEFAULT_CARGO_TOML: + continue + data = tomllib.loads(cargo_toml.read_text()) + if data.get("lints", {}).get("workspace") is True: + return str(cargo_toml.relative_to(ROOT)) + return None + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/verify_cargo_workspace_manifests.py b/.github/scripts/verify_cargo_workspace_manifests.py new file mode 100644 index 0000000000..4812e2428d --- /dev/null +++ b/.github/scripts/verify_cargo_workspace_manifests.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 + +"""Verify that codex-rs Cargo manifests follow workspace manifest policy. + +Checks: +- Crates inherit `[workspace.package]` metadata. +- Crates opt into `[lints] workspace = true`. +- Crate names follow the codex-rs directory naming conventions. +- Workspace manifests do not introduce workspace crate feature toggles. +""" + +from __future__ import annotations + +import sys +import tomllib +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +CARGO_RS_ROOT = ROOT / "codex-rs" +WORKSPACE_PACKAGE_FIELDS = ("version", "edition", "license") +TOP_LEVEL_NAME_EXCEPTIONS = { + "windows-sandbox-rs": "codex-windows-sandbox", +} +UTILITY_NAME_EXCEPTIONS = { + "path-utils": "codex-utils-path", +} +MANIFEST_FEATURE_EXCEPTIONS = {} +OPTIONAL_DEPENDENCY_EXCEPTIONS = set() +INTERNAL_DEPENDENCY_FEATURE_EXCEPTIONS = {} + + +def main() -> int: + internal_package_names = workspace_package_names() + used_manifest_feature_exceptions: set[str] = set() + used_optional_dependency_exceptions: set[tuple[str, str, str]] = set() + used_internal_dependency_feature_exceptions: set[tuple[str, str, str]] = set() + failures_by_path: dict[str, list[str]] = {} + + for path in manifests_to_verify(): + if errors := manifest_errors( + path, + internal_package_names, + used_manifest_feature_exceptions, + used_optional_dependency_exceptions, + used_internal_dependency_feature_exceptions, + ): + failures_by_path[manifest_key(path)] = errors + + add_unused_exception_errors( + failures_by_path, + used_manifest_feature_exceptions, + used_optional_dependency_exceptions, + used_internal_dependency_feature_exceptions, + ) + + if not failures_by_path: + return 0 + + print( + "Cargo manifests under codex-rs must inherit workspace package metadata, " + "opt into workspace lints, and avoid introducing new workspace crate " + "features." + ) + print( + "Workspace crate features are disallowed because our Bazel build setup " + "does not honor them today, which can let issues hidden behind feature " + "gates go unnoticed, and because they add extra crate build " + "permutations we want to avoid." + ) + print( + "Cargo only applies `codex-rs/Cargo.toml` `[workspace.lints.clippy]` " + "entries to a crate when that crate declares:" + ) + print() + print("[lints]") + print("workspace = true") + print() + print( + "Without that opt-in, `cargo clippy` can miss violations that Bazel clippy " + "catches." + ) + print() + print( + "Package-name checks apply to `codex-rs//Cargo.toml` and " + "`codex-rs/utils//Cargo.toml`." + ) + print( + "Workspace crate features are forbidden; add a targeted exception here " + "only if there is a deliberate temporary migration in flight." + ) + print() + for path in sorted(failures_by_path): + errors = failures_by_path[path] + print(f"{path}:") + for error in errors: + print(f" - {error}") + + return 1 + + +def manifest_errors( + path: Path, + internal_package_names: set[str], + used_manifest_feature_exceptions: set[str], + used_optional_dependency_exceptions: set[tuple[str, str, str]], + used_internal_dependency_feature_exceptions: set[tuple[str, str, str]], +) -> list[str]: + manifest = load_manifest(path) + package = manifest.get("package") + if not isinstance(package, dict) and path != CARGO_RS_ROOT / "Cargo.toml": + return [] + + errors = [] + if isinstance(package, dict): + for field in WORKSPACE_PACKAGE_FIELDS: + if not is_workspace_reference(package.get(field)): + errors.append(f"set `{field}.workspace = true` in `[package]`") + + lints = manifest.get("lints") + if not (isinstance(lints, dict) and lints.get("workspace") is True): + errors.append("add `[lints]` with `workspace = true`") + + expected_name = expected_package_name(path) + if expected_name is not None: + actual_name = package.get("name") + if actual_name != expected_name: + errors.append( + f"set `[package].name` to `{expected_name}` (found `{actual_name}`)" + ) + + path_key = manifest_key(path) + features = manifest.get("features") + if features is not None: + normalized_features = normalize_feature_mapping(features) + expected_features = MANIFEST_FEATURE_EXCEPTIONS.get(path_key) + if expected_features is None: + errors.append( + "remove `[features]`; new workspace crate features are not allowed" + ) + else: + used_manifest_feature_exceptions.add(path_key) + if normalized_features != expected_features: + errors.append( + "limit `[features]` to the existing exception list while " + "workspace crate features are being removed " + f"(expected {render_feature_mapping(expected_features)})" + ) + + for section_name, dependencies in dependency_sections(manifest): + for dependency_name, dependency in dependencies.items(): + if not isinstance(dependency, dict): + continue + + if dependency.get("optional") is True: + exception_key = (path_key, section_name, dependency_name) + if exception_key in OPTIONAL_DEPENDENCY_EXCEPTIONS: + used_optional_dependency_exceptions.add(exception_key) + else: + errors.append( + "remove `optional = true` from " + f"`{dependency_entry_label(section_name, dependency_name)}`; " + "new optional dependencies are not allowed because they " + "create crate features" + ) + + if not is_internal_dependency(path, dependency_name, dependency, internal_package_names): + continue + + dependency_features = dependency.get("features") + if dependency_features is not None: + normalized_dependency_features = normalize_string_list( + dependency_features + ) + exception_key = (path_key, section_name, dependency_name) + expected_dependency_features = ( + INTERNAL_DEPENDENCY_FEATURE_EXCEPTIONS.get(exception_key) + ) + if expected_dependency_features is None: + errors.append( + "remove `features = [...]` from workspace dependency " + f"`{dependency_entry_label(section_name, dependency_name)}`; " + "new workspace crate feature activations are not allowed" + ) + else: + used_internal_dependency_feature_exceptions.add(exception_key) + if normalized_dependency_features != expected_dependency_features: + errors.append( + "limit workspace dependency features on " + f"`{dependency_entry_label(section_name, dependency_name)}` " + "to the existing exception list while workspace crate " + "features are being removed " + f"(expected {render_string_list(expected_dependency_features)})" + ) + + if dependency.get("default-features") is False: + errors.append( + "remove `default-features = false` from workspace dependency " + f"`{dependency_entry_label(section_name, dependency_name)}`; " + "new workspace crate feature toggles are not allowed" + ) + + return errors + + +def expected_package_name(path: Path) -> str | None: + parts = path.relative_to(CARGO_RS_ROOT).parts + if len(parts) == 2 and parts[1] == "Cargo.toml": + directory = parts[0] + return TOP_LEVEL_NAME_EXCEPTIONS.get( + directory, + directory if directory.startswith("codex-") else f"codex-{directory}", + ) + if len(parts) == 3 and parts[0] == "utils" and parts[2] == "Cargo.toml": + directory = parts[1] + return UTILITY_NAME_EXCEPTIONS.get(directory, f"codex-utils-{directory}") + return None + + +def is_workspace_reference(value: object) -> bool: + return isinstance(value, dict) and value.get("workspace") is True + + +def manifest_key(path: Path) -> str: + return str(path.relative_to(ROOT)) + + +def normalize_feature_mapping(value: object) -> dict[str, tuple[str, ...]] | None: + if not isinstance(value, dict): + return None + + normalized = {} + for key, features in value.items(): + if not isinstance(key, str): + return None + normalized_features = normalize_string_list(features) + if normalized_features is None: + return None + normalized[key] = normalized_features + return normalized + + +def normalize_string_list(value: object) -> tuple[str, ...] | None: + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + return None + return tuple(value) + + +def render_feature_mapping(features: dict[str, tuple[str, ...]]) -> str: + entries = [ + f"{name} = {render_string_list(items)}" for name, items in features.items() + ] + return ", ".join(entries) + + +def render_string_list(items: tuple[str, ...]) -> str: + return "[" + ", ".join(f'"{item}"' for item in items) + "]" + + +def dependency_sections(manifest: dict) -> list[tuple[str, dict]]: + sections = [] + for section_name in ("dependencies", "dev-dependencies", "build-dependencies"): + dependencies = manifest.get(section_name) + if isinstance(dependencies, dict): + sections.append((section_name, dependencies)) + + workspace = manifest.get("workspace") + if isinstance(workspace, dict): + workspace_dependencies = workspace.get("dependencies") + if isinstance(workspace_dependencies, dict): + sections.append(("workspace.dependencies", workspace_dependencies)) + + target = manifest.get("target") + if not isinstance(target, dict): + return sections + + for target_name, tables in target.items(): + if not isinstance(tables, dict): + continue + for section_name in ("dependencies", "dev-dependencies", "build-dependencies"): + dependencies = tables.get(section_name) + if isinstance(dependencies, dict): + sections.append((f"target.{target_name}.{section_name}", dependencies)) + + return sections + + +def dependency_entry_label(section_name: str, dependency_name: str) -> str: + return f"[{section_name}].{dependency_name}" + + +def is_internal_dependency( + manifest_path: Path, + dependency_name: str, + dependency: dict, + internal_package_names: set[str], +) -> bool: + package_name = dependency.get("package", dependency_name) + if isinstance(package_name, str) and package_name in internal_package_names: + return True + + dependency_path = dependency.get("path") + if not isinstance(dependency_path, str): + return False + + resolved_dependency_path = (manifest_path.parent / dependency_path).resolve() + try: + resolved_dependency_path.relative_to(CARGO_RS_ROOT) + except ValueError: + return False + return True + + +def add_unused_exception_errors( + failures_by_path: dict[str, list[str]], + used_manifest_feature_exceptions: set[str], + used_optional_dependency_exceptions: set[tuple[str, str, str]], + used_internal_dependency_feature_exceptions: set[tuple[str, str, str]], +) -> None: + for path_key in sorted( + set(MANIFEST_FEATURE_EXCEPTIONS) - used_manifest_feature_exceptions + ): + add_failure( + failures_by_path, + path_key, + "remove the stale `[features]` exception from " + "`MANIFEST_FEATURE_EXCEPTIONS`", + ) + + for path_key, section_name, dependency_name in sorted( + OPTIONAL_DEPENDENCY_EXCEPTIONS - used_optional_dependency_exceptions + ): + add_failure( + failures_by_path, + path_key, + "remove the stale optional-dependency exception for " + f"`{dependency_entry_label(section_name, dependency_name)}` from " + "`OPTIONAL_DEPENDENCY_EXCEPTIONS`", + ) + + for path_key, section_name, dependency_name in sorted( + set(INTERNAL_DEPENDENCY_FEATURE_EXCEPTIONS) + - used_internal_dependency_feature_exceptions + ): + add_failure( + failures_by_path, + path_key, + "remove the stale internal dependency feature exception for " + f"`{dependency_entry_label(section_name, dependency_name)}` from " + "`INTERNAL_DEPENDENCY_FEATURE_EXCEPTIONS`", + ) + + +def add_failure(failures_by_path: dict[str, list[str]], path_key: str, error: str) -> None: + failures_by_path.setdefault(path_key, []).append(error) + + +def workspace_package_names() -> set[str]: + package_names = set() + for path in cargo_manifests(): + manifest = load_manifest(path) + package = manifest.get("package") + if not isinstance(package, dict): + continue + package_name = package.get("name") + if isinstance(package_name, str): + package_names.add(package_name) + return package_names + + +def load_manifest(path: Path) -> dict: + return tomllib.loads(path.read_text()) + + +def cargo_manifests() -> list[Path]: + return sorted( + path + for path in CARGO_RS_ROOT.rglob("Cargo.toml") + if path != CARGO_RS_ROOT / "Cargo.toml" + ) + + +def manifests_to_verify() -> list[Path]: + return [CARGO_RS_ROOT / "Cargo.toml", *cargo_manifests()] + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000000..d14817f002 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,33 @@ +# Workflow Strategy + +The workflows in this directory are split so that pull requests get fast, review-friendly signal while `main` still gets the full cross-platform verification pass. + +## Pull Requests + +- `bazel.yml` is the main pre-merge verification path for Rust code. + It runs Bazel `test` and Bazel `clippy` on the supported Bazel targets, + including the generated Rust test binaries needed to lint inline `#[cfg(test)]` + code. +- `rust-ci.yml` keeps the Cargo-native PR checks intentionally small: + - `cargo fmt --check` + - `cargo shear` + - `argument-comment-lint` on Linux, macOS, and Windows + - `tools/argument-comment-lint` package tests when the lint or its workflow wiring changes + +## Post-Merge On `main` + +- `bazel.yml` also runs on pushes to `main`. + This re-verifies the merged Bazel path and helps keep the BuildBuddy caches warm. +- `rust-ci-full.yml` is the full Cargo-native verification workflow. + It keeps the heavier checks off the PR path while still validating them after merge: + - the full Cargo `clippy` matrix + - the full Cargo `nextest` matrix + - release-profile Cargo builds + - cross-platform `argument-comment-lint` + - Linux remote-env tests + +## Rule Of Thumb + +- If a build/test/clippy check can be expressed in Bazel, prefer putting the PR-time version in `bazel.yml`. +- Keep `rust-ci.yml` fast enough that it usually does not dominate PR latency. +- Reserve `rust-ci-full.yml` for heavyweight Cargo-native coverage that Bazel does not replace yet. diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index b2ef107ca7..2e14184800 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -1,4 +1,4 @@ -name: Bazel (experimental) +name: Bazel # Note this workflow was originally derived from: # https://github.com/cerisier/toolchains_llvm_bootstrapped/blob/main/.github/workflows/ci.yaml @@ -17,6 +17,7 @@ concurrency: cancel-in-progress: ${{ github.ref_name != 'main' }} jobs: test: + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -39,193 +40,192 @@ jobs: # - os: ubuntu-24.04-arm # target: aarch64-unknown-linux-gnu - # TODO: Enable Windows once we fix the toolchain issues there. - #- os: windows-latest - # target: x86_64-pc-windows-gnullvm + # Windows + - os: windows-latest + target: x86_64-pc-windows-gnullvm runs-on: ${{ matrix.os }} # Configure a human readable name for each job name: Local Bazel build on ${{ matrix.os }} for ${{ matrix.target }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Set up Node.js for js_repl tests - uses: actions/setup-node@v6 + - name: Set up Bazel CI + id: setup_bazel + uses: ./.github/actions/setup-bazel-ci with: - node-version-file: codex-rs/node-version.txt + target: ${{ matrix.target }} + install-test-prereqs: "true" - # Some integration tests rely on DotSlash being installed. - # See https://github.com/openai/codex/pull/7617. - - name: Install DotSlash - uses: facebook/install-dotslash@v2 - - - name: Make DotSlash available in PATH (Unix) - if: runner.os != 'Windows' - run: cp "$(which dotslash)" /usr/local/bin - - - name: Make DotSlash available in PATH (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: Copy-Item (Get-Command dotslash).Source -Destination "$env:LOCALAPPDATA\Microsoft\WindowsApps\dotslash.exe" - - # Install Bazel via Bazelisk - - name: Set up Bazel - uses: bazelbuild/setup-bazelisk@v3 + # Restore the Bazel repository cache explicitly so external dependencies + # do not need to be re-downloaded on every CI run. Keep restore failures + # non-fatal so transient cache-service errors degrade to a cold build + # instead of failing the job. + - name: Restore bazel repository cache + id: cache_bazel_repository_restore + continue-on-error: true + uses: actions/cache/restore@v5 + with: + path: ${{ steps.setup_bazel.outputs.repository-cache-path }} + key: bazel-cache-${{ matrix.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }} + restore-keys: | + bazel-cache-${{ matrix.target }} - name: Check MODULE.bazel.lock is up to date if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu' shell: bash run: ./scripts/check-module-bazel-lock.sh - # TODO(mbolin): Bring this back once we have caching working. Currently, - # we never seem to get a cache hit but we still end up paying the cost of - # uploading at the end of the build, which takes over a minute! - # - # Cache build and external artifacts so that the next ci build is incremental. - # Because github action caches cannot be updated after a build, we need to - # store the contents of each build in a unique cache key, then fall back to loading - # it on the next ci run. We use hashFiles(...) in the key and restore-keys- with - # the prefix to load the most recent cache for the branch on a cache miss. You - # should customize the contents of hashFiles to capture any bazel input sources, - # although this doesn't need to be perfect. If none of the input sources change - # then a cache hit will load an existing cache and bazel won't have to do any work. - # In the case of a cache miss, you want the fallback cache to contain most of the - # previously built artifacts to minimize build time. The more precise you are with - # hashFiles sources the less work bazel will have to do. - # - name: Mount bazel caches - # uses: actions/cache@v5 - # with: - # path: | - # ~/.cache/bazel-repo-cache - # ~/.cache/bazel-repo-contents-cache - # key: bazel-cache-${{ matrix.os }}-${{ hashFiles('**/BUILD.bazel', '**/*.bzl', 'MODULE.bazel') }} - # restore-keys: | - # bazel-cache-${{ matrix.os }} - - - name: Configure Bazel startup args (Windows) - if: runner.os == 'Windows' - shell: pwsh + - name: Set up Bazel execution logs + shell: bash run: | - # Use a very short path to reduce argv/path length issues. - "BAZEL_STARTUP_ARGS=--output_user_root=C:\" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + mkdir -p "${RUNNER_TEMP}/bazel-execution-logs" + echo "CODEX_BAZEL_EXECUTION_LOG_COMPACT_DIR=${RUNNER_TEMP}/bazel-execution-logs" >> "${GITHUB_ENV}" - name: bazel test //... env: BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} shell: bash run: | - set -o pipefail - - bazel_console_log="$(mktemp)" - - print_failed_bazel_test_logs() { - local console_log="$1" - local testlogs_dir - - testlogs_dir="$(bazel $BAZEL_STARTUP_ARGS info bazel-testlogs 2>/dev/null || echo bazel-testlogs)" - - local failed_targets=() - while IFS= read -r target; do - failed_targets+=("$target") - done < <( - grep -E '^FAIL: //' "$console_log" \ - | sed -E 's#^FAIL: (//[^ ]+).*#\1#' \ - | sort -u - ) - - if [[ ${#failed_targets[@]} -eq 0 ]]; then - echo "No failed Bazel test targets were found in console output." - return - fi - - for target in "${failed_targets[@]}"; do - local rel_path="${target#//}" - rel_path="${rel_path/:/\/}" - local test_log="${testlogs_dir}/${rel_path}/test.log" - - echo "::group::Bazel test log tail for ${target}" - if [[ -f "$test_log" ]]; then - tail -n 200 "$test_log" - else - echo "Missing test log: $test_log" - fi - echo "::endgroup::" - done - } - - bazel_args=( - test - --test_verbose_timeout_warnings - --build_metadata=REPO_URL=https://github.com/openai/codex.git - --build_metadata=COMMIT_SHA=$(git rev-parse HEAD) - --build_metadata=ROLE=CI - --build_metadata=VISIBILITY=PUBLIC - ) - bazel_targets=( //... - # Keep V8 out of the ordinary Bazel CI path. Only the dedicated - # canary and release workflows should build `third_party/v8`. + # Keep standalone V8 library targets out of the ordinary Bazel CI + # path. V8 consumers under `//codex-rs/...` still participate + # transitively through `//...`. -//third_party/v8:all ) - if [[ "${RUNNER_OS:-}" != "Windows" ]]; then - # Bazel test sandboxes on macOS may resolve an older Homebrew `node` - # before the `actions/setup-node` runtime on PATH. - node_bin="$(which node)" - bazel_args+=("--test_env=CODEX_JS_REPL_NODE_PATH=${node_bin}") + bazel_wrapper_args=( + --print-failed-test-logs + --use-node-test-env + ) + if [[ "${RUNNER_OS}" == "Windows" ]]; then + bazel_wrapper_args+=(--windows-msvc-host-platform) fi - if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then - echo "BuildBuddy API key is available; using remote Bazel configuration." - # Work around Bazel 9 remote repo contents cache / overlay materialization failures - # seen in CI (for example "is not a symlink" or permission errors while - # materializing external repos such as rules_perl). We still use BuildBuddy for - # remote execution/cache; this only disables the startup-level repo contents cache. - set +e - bazel $BAZEL_STARTUP_ARGS \ - --noexperimental_remote_repo_contents_cache \ - --bazelrc=.github/workflows/ci.bazelrc \ - "${bazel_args[@]}" \ - "--remote_header=x-buildbuddy-api-key=$BUILDBUDDY_API_KEY" \ - -- \ - "${bazel_targets[@]}" \ - 2>&1 | tee "$bazel_console_log" - bazel_status=${PIPESTATUS[0]} - set -e - else - echo "BuildBuddy API key is not available; using local Bazel configuration." - # Keep fork/community PRs on Bazel but disable remote services that are - # configured in .bazelrc and require auth. - # - # Flag docs: - # - Command-line reference: https://bazel.build/reference/command-line-reference - # - Remote caching overview: https://bazel.build/remote/caching - # - Remote execution overview: https://bazel.build/remote/rbe - # - Build Event Protocol overview: https://bazel.build/remote/bep - # - # --noexperimental_remote_repo_contents_cache: - # disable remote repo contents cache enabled in .bazelrc startup options. - # https://bazel.build/reference/command-line-reference#startup_options-flag--experimental_remote_repo_contents_cache - # --remote_cache= and --remote_executor=: - # clear remote cache/execution endpoints configured in .bazelrc. - # https://bazel.build/reference/command-line-reference#common_options-flag--remote_cache - # https://bazel.build/reference/command-line-reference#common_options-flag--remote_executor - set +e - bazel $BAZEL_STARTUP_ARGS \ - --noexperimental_remote_repo_contents_cache \ - "${bazel_args[@]}" \ - --remote_cache= \ - --remote_executor= \ - -- \ - "${bazel_targets[@]}" \ - 2>&1 | tee "$bazel_console_log" - bazel_status=${PIPESTATUS[0]} - set -e + ./.github/scripts/run-bazel-ci.sh \ + "${bazel_wrapper_args[@]}" \ + -- \ + test \ + --test_tag_filters=-argument-comment-lint \ + --test_verbose_timeout_warnings \ + --build_metadata=COMMIT_SHA=${GITHUB_SHA} \ + -- \ + "${bazel_targets[@]}" + + - name: Upload Bazel execution logs + if: always() && !cancelled() + continue-on-error: true + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: bazel-execution-logs-test-${{ matrix.target }} + path: ${{ runner.temp }}/bazel-execution-logs + if-no-files-found: ignore + + # Save bazel repository cache explicitly; make non-fatal so cache uploading + # never fails the overall job. Only save when key wasn't hit. + - name: Save bazel repository cache + if: always() && !cancelled() && steps.cache_bazel_repository_restore.outputs.cache-hit != 'true' + continue-on-error: true + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + with: + path: ${{ steps.setup_bazel.outputs.repository-cache-path }} + key: bazel-cache-${{ matrix.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }} + + clippy: + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + # Keep Linux lint coverage on x64 and add the arm64 macOS path that + # the Bazel test job already exercises. Add Windows gnullvm as well + # so PRs get Bazel-native lint signal on the same Windows toolchain + # that the Bazel test job uses. + - os: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + - os: macos-15-xlarge + target: aarch64-apple-darwin + - os: windows-latest + target: x86_64-pc-windows-gnullvm + runs-on: ${{ matrix.os }} + name: Bazel clippy on ${{ matrix.os }} for ${{ matrix.target }} + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Bazel CI + id: setup_bazel + uses: ./.github/actions/setup-bazel-ci + with: + target: ${{ matrix.target }} + + # Restore the Bazel repository cache explicitly so external dependencies + # do not need to be re-downloaded on every CI run. Keep restore failures + # non-fatal so transient cache-service errors degrade to a cold build + # instead of failing the job. + - name: Restore bazel repository cache + id: cache_bazel_repository_restore + continue-on-error: true + uses: actions/cache/restore@v5 + with: + path: ${{ steps.setup_bazel.outputs.repository-cache-path }} + key: bazel-cache-${{ matrix.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }} + restore-keys: | + bazel-cache-${{ matrix.target }} + + - name: Set up Bazel execution logs + shell: bash + run: | + mkdir -p "${RUNNER_TEMP}/bazel-execution-logs" + echo "CODEX_BAZEL_EXECUTION_LOG_COMPACT_DIR=${RUNNER_TEMP}/bazel-execution-logs" >> "${GITHUB_ENV}" + + - name: bazel build --config=clippy lint targets + env: + BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} + shell: bash + run: | + bazel_clippy_args=( + --config=clippy + --build_metadata=COMMIT_SHA=${GITHUB_SHA} + --build_metadata=TAG_job=clippy + ) + if [[ "${RUNNER_OS}" == "Windows" ]]; then + # Some explicit targets pulled in through //codex-rs/... are + # intentionally incompatible with `//:local_windows`, but the lint + # aspect still traverses their compatible Rust deps. + bazel_clippy_args+=(--skip_incompatible_explicit_targets) fi - if [[ ${bazel_status:-0} -ne 0 ]]; then - print_failed_bazel_test_logs "$bazel_console_log" - exit "$bazel_status" - fi + bazel_target_lines="$(./scripts/list-bazel-clippy-targets.sh)" + bazel_targets=() + while IFS= read -r target; do + bazel_targets+=("${target}") + done <<< "${bazel_target_lines}" + + ./.github/scripts/run-bazel-ci.sh \ + -- \ + build \ + "${bazel_clippy_args[@]}" \ + -- \ + "${bazel_targets[@]}" + + - name: Upload Bazel execution logs + if: always() && !cancelled() + continue-on-error: true + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: bazel-execution-logs-clippy-${{ matrix.target }} + path: ${{ runner.temp }}/bazel-execution-logs + if-no-files-found: ignore + + # Save bazel repository cache explicitly; make non-fatal so cache uploading + # never fails the overall job. Only save when key wasn't hit. + - name: Save bazel repository cache + if: always() && !cancelled() + continue-on-error: true + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + with: + path: ${{ steps.setup_bazel.outputs.repository-cache-path }} + key: bazel-cache-${{ matrix.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }} diff --git a/.github/workflows/blob-size-policy.yml b/.github/workflows/blob-size-policy.yml index bce6e49790..b96cb98c30 100644 --- a/.github/workflows/blob-size-policy.yml +++ b/.github/workflows/blob-size-policy.yml @@ -8,7 +8,7 @@ jobs: name: Blob size policy runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 diff --git a/.github/workflows/cargo-deny.yml b/.github/workflows/cargo-deny.yml index 60adb38710..5294d0c7c5 100644 --- a/.github/workflows/cargo-deny.yml +++ b/.github/workflows/cargo-deny.yml @@ -14,13 +14,13 @@ jobs: working-directory: ./codex-rs steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable - name: Run cargo-deny - uses: EmbarkStudios/cargo-deny-action@v2 + uses: EmbarkStudios/cargo-deny-action@82eb9f621fbc699dd0918f3ea06864c14cc84246 # v2 with: rust-version: stable manifest-path: ./codex-rs/Cargo.toml diff --git a/.github/workflows/ci.bazelrc b/.github/workflows/ci.bazelrc deleted file mode 100644 index 997a774eb3..0000000000 --- a/.github/workflows/ci.bazelrc +++ /dev/null @@ -1,27 +0,0 @@ -common --remote_download_minimal -common --keep_going -common --verbose_failures - -# Disable disk cache since we have remote one and aren't using persistent workers. -common --disk_cache= - -# Rearrange caches on Windows so they're on the same volume as the checkout. -common:windows --repo_contents_cache=D:/a/.cache/bazel-repo-contents-cache -common:windows --repository_cache=D:/a/.cache/bazel-repo-cache - -# We prefer to run the build actions entirely remotely so we can dial up the concurrency. -# We have platform-specific tests, so we want to execute the tests on all platforms using the strongest sandboxing available on each platform. - -# On linux, we can do a full remote build/test, by targeting the right (x86/arm) runners, so we have coverage of both. -# Linux crossbuilds don't work until we untangle the libc constraint mess. -common:linux --config=remote -common:linux --strategy=remote -common:linux --platforms=//:rbe - -# On mac, we can run all the build actions remotely but test actions locally. -common:macos --config=remote -common:macos --strategy=remote -common:macos --strategy=TestRunner=darwin-sandbox,local - -# On windows we cannot cross-build the tests but run them locally due to what appears to be a Bazel bug -# (windows vs unix path confusion) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbd2df27cf..76a8aa0148 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,15 +12,21 @@ jobs: NODE_OPTIONS: --max-old-space-size=4096 steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Verify codex-rs Cargo manifests inherit workspace settings + run: python3 .github/scripts/verify_cargo_workspace_manifests.py + + - name: Verify Bazel clippy flags match Cargo workspace lints + run: python3 .github/scripts/verify_bazel_clippy_lints.py - name: Setup pnpm - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 with: run_install: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: 22 @@ -28,7 +34,7 @@ jobs: run: pnpm install --frozen-lockfile # stage_npm_packages.py requires DotSlash when staging releases. - - uses: facebook/install-dotslash@v2 + - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 - name: Stage npm package id: stage_npm_package @@ -47,7 +53,7 @@ jobs: echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT" - name: Upload staged npm package artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: codex-npm-staging path: ${{ steps.stage_npm_package.outputs.pack_output }} diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index bab34d0365..b48fd36fea 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -18,7 +18,7 @@ jobs: if: ${{ github.repository_owner == 'openai' }} runs-on: ubuntu-latest steps: - - uses: contributor-assistant/github-action@v2.6.1 + - uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1 # Run on close only if the PR was merged. This will lock the PR to preserve # the CLA agreement. We don't want to lock PRs that have been closed without # merging because the contributor may want to respond with additional comments. diff --git a/.github/workflows/close-stale-contributor-prs.yml b/.github/workflows/close-stale-contributor-prs.yml index 43e6992883..8fb5132772 100644 --- a/.github/workflows/close-stale-contributor-prs.yml +++ b/.github/workflows/close-stale-contributor-prs.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Close inactive PRs from contributors - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index bbbb06d06f..8e9f701eec 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Annotate locations with typos uses: codespell-project/codespell-problem-matcher@b80729f885d32f78a716c2f107b4db1025001c42 # v1 - name: Codespell diff --git a/.github/workflows/issue-deduplicator.yml b/.github/workflows/issue-deduplicator.yml index 6f4df87f43..c0fadbcf18 100644 --- a/.github/workflows/issue-deduplicator.yml +++ b/.github/workflows/issue-deduplicator.yml @@ -19,7 +19,7 @@ jobs: reason: ${{ steps.normalize-all.outputs.reason }} has_matches: ${{ steps.normalize-all.outputs.has_matches }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Prepare Codex inputs env: @@ -61,7 +61,7 @@ jobs: # .github/prompts/issue-deduplicator.txt file is obsolete and removed. - id: codex-all name: Find duplicates (pass 1, all issues) - uses: openai/codex-action@main + uses: openai/codex-action@0b91f4a2703c23df3102c3f0967d3c6db34eedef # v1 with: openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }} allow-users: "*" @@ -155,7 +155,7 @@ jobs: reason: ${{ steps.normalize-open.outputs.reason }} has_matches: ${{ steps.normalize-open.outputs.has_matches }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Prepare Codex inputs env: @@ -195,7 +195,7 @@ jobs: - id: codex-open name: Find duplicates (pass 2, open issues) - uses: openai/codex-action@main + uses: openai/codex-action@0b91f4a2703c23df3102c3f0967d3c6db34eedef # v1 with: openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }} allow-users: "*" @@ -342,7 +342,7 @@ jobs: issues: write steps: - name: Comment on issue - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: CODEX_OUTPUT: ${{ needs.select-final.outputs.codex_output }} with: diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml index 174b219de9..1173685890 100644 --- a/.github/workflows/issue-labeler.yml +++ b/.github/workflows/issue-labeler.yml @@ -17,10 +17,10 @@ jobs: outputs: codex_output: ${{ steps.codex.outputs.final-message }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - id: codex - uses: openai/codex-action@main + uses: openai/codex-action@0b91f4a2703c23df3102c3f0967d3c6db34eedef # v1 with: openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }} allow-users: "*" @@ -52,13 +52,15 @@ jobs: 6. code-review — Issues related to the code review feature or functionality. 7. safety-check - Issues related to cyber risk detection or trusted access verification. 8. auth - Problems related to authentication, login, or access tokens. - 9. codex-exec - Problems related to the "codex exec" command or functionality. - 10. context-management - Problems related to compaction, context windows, or available context reporting. - 11. custom-model - Problems that involve using custom model providers, local models, or OSS models. - 12. rate-limits - Problems related to token limits, rate limits, or token usage reporting. - 13. sandbox - Issues related to local sandbox environments or tool call approvals to override sandbox restrictions. - 14. tool-calls - Problems related to specific tool call invocations including unexpected errors, failures, or hangs. - 15. TUI - Problems with the terminal user interface (TUI) including keyboard shortcuts, copy & pasting, menus, or screen update issues. + 9. exec - Problems related to the "codex exec" command or functionality. + 10. hooks - Problems related to event hooks + 11. context - Problems related to compaction, context windows, or available context reporting. + 12. skills - Problems related to skills or plugins + 13. custom-model - Problems that involve using custom model providers, local models, or OSS models. + 14. rate-limits - Problems related to token limits, rate limits, or token usage reporting. + 15. sandbox - Issues related to local sandbox environments or tool call approvals to override sandbox restrictions. + 16. tool-calls - Problems related to specific tool call invocations including unexpected errors, failures, or hangs. + 17. TUI - Problems with the terminal user interface (TUI) including keyboard shortcuts, copy & pasting, menus, or screen update issues. Issue number: ${{ github.event.issue.number }} diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml new file mode 100644 index 0000000000..97fa33283e --- /dev/null +++ b/.github/workflows/rust-ci-full.yml @@ -0,0 +1,776 @@ +name: rust-ci-full +on: + push: + branches: + - main + - "**full-ci**" + workflow_dispatch: + +# CI builds in debug (dev) for faster signal. + +jobs: + # --- CI that doesn't need specific targets --------------------------------- + general: + name: Format / etc + runs-on: ubuntu-24.04 + defaults: + run: + working-directory: codex-rs + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + with: + components: rustfmt + - name: cargo fmt + run: cargo fmt -- --config imports_granularity=Item --check + + cargo_shear: + name: cargo shear + runs-on: ubuntu-24.04 + defaults: + run: + working-directory: codex-rs + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 + with: + tool: cargo-shear + version: 1.5.1 + - name: cargo shear + run: cargo shear + + argument_comment_lint_package: + name: Argument comment lint package + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + with: + toolchain: nightly-2025-09-18 + components: llvm-tools-preview, rustc-dev, rust-src + - name: Cache cargo-dylint tooling + id: cargo_dylint_cache + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + with: + path: | + ~/.cargo/bin/cargo-dylint + ~/.cargo/bin/dylint-link + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + key: argument-comment-lint-${{ runner.os }}-${{ hashFiles('tools/argument-comment-lint/Cargo.lock', 'tools/argument-comment-lint/rust-toolchain', '.github/workflows/rust-ci.yml', '.github/workflows/rust-ci-full.yml') }} + - name: Install cargo-dylint tooling + if: ${{ steps.cargo_dylint_cache.outputs.cache-hit != 'true' }} + run: cargo install --locked cargo-dylint dylint-link + - name: Check Python wrapper syntax + run: python3 -m py_compile tools/argument-comment-lint/wrapper_common.py tools/argument-comment-lint/run.py tools/argument-comment-lint/run-prebuilt-linter.py tools/argument-comment-lint/test_wrapper_common.py + - name: Test Python wrapper helpers + run: python3 -m unittest discover -s tools/argument-comment-lint -p 'test_*.py' + - name: Test argument comment lint package + working-directory: tools/argument-comment-lint + run: cargo test + + argument_comment_lint_prebuilt: + name: Argument comment lint - ${{ matrix.name }} + runs-on: ${{ matrix.runs_on || matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - name: Linux + runner: ubuntu-24.04 + - name: macOS + runner: macos-15-xlarge + - name: Windows + runner: windows-x64 + runs_on: + group: codex-runners + labels: codex-windows-x64 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: ./.github/actions/setup-bazel-ci + with: + target: ${{ runner.os }} + install-test-prereqs: true + - name: Install Linux sandbox build dependencies + if: ${{ runner.os == 'Linux' }} + shell: bash + run: | + sudo DEBIAN_FRONTEND=noninteractive apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev + - name: Run argument comment lint on codex-rs via Bazel + if: ${{ runner.os != 'Windows' }} + env: + BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} + shell: bash + run: | + bazel_targets="$(./tools/argument-comment-lint/list-bazel-targets.sh)" + ./.github/scripts/run-bazel-ci.sh \ + -- \ + build \ + --config=argument-comment-lint \ + --keep_going \ + --build_metadata=COMMIT_SHA=${GITHUB_SHA} \ + -- \ + ${bazel_targets} + - name: Run argument comment lint on codex-rs via Bazel + if: ${{ runner.os == 'Windows' }} + env: + BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} + shell: bash + run: | + ./.github/scripts/run-argument-comment-lint-bazel.sh \ + --config=argument-comment-lint \ + --platforms=//:local_windows \ + --keep_going \ + --build_metadata=COMMIT_SHA=${GITHUB_SHA} + + # --- CI to validate on different os/targets -------------------------------- + lint_build: + name: Lint/Build — ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.profile == 'release' && ' (release)' || '' }} + runs-on: ${{ matrix.runs_on || matrix.runner }} + timeout-minutes: 30 + defaults: + run: + working-directory: codex-rs + env: + # Speed up repeated builds across CI runs by caching compiled objects, except on + # arm64 macOS runners cross-targeting x86_64 where ring/cc-rs can produce + # mixed-architecture archives under sccache. + USE_SCCACHE: ${{ (startsWith(matrix.runner, 'windows') || (matrix.runner == 'macos-15-xlarge' && matrix.target == 'x86_64-apple-darwin')) && 'false' || 'true' }} + CARGO_INCREMENTAL: "0" + SCCACHE_CACHE_SIZE: 10G + # In rust-ci, representative release-profile checks use thin LTO for faster feedback. + CARGO_PROFILE_RELEASE_LTO: ${{ matrix.profile == 'release' && 'thin' || 'fat' }} + + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15-xlarge + target: aarch64-apple-darwin + profile: dev + - runner: macos-15-xlarge + target: x86_64-apple-darwin + profile: dev + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-musl + profile: dev + runs_on: + group: codex-runners + labels: codex-linux-x64 + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + profile: dev + runs_on: + group: codex-runners + labels: codex-linux-x64 + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + profile: dev + runs_on: + group: codex-runners + labels: codex-linux-arm64 + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + profile: dev + runs_on: + group: codex-runners + labels: codex-linux-arm64 + - runner: windows-x64 + target: x86_64-pc-windows-msvc + profile: dev + runs_on: + group: codex-runners + labels: codex-windows-x64 + - runner: windows-arm64 + target: aarch64-pc-windows-msvc + profile: dev + runs_on: + group: codex-runners + labels: codex-windows-arm64 + + # Also run representative release builds on Mac and Linux because + # there could be release-only build errors we want to catch. + # Hopefully this also pre-populates the build cache to speed up + # releases. + - runner: macos-15-xlarge + target: aarch64-apple-darwin + profile: release + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-musl + profile: release + runs_on: + group: codex-runners + labels: codex-linux-x64 + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + profile: release + runs_on: + group: codex-runners + labels: codex-linux-arm64 + - runner: windows-x64 + target: x86_64-pc-windows-msvc + profile: release + runs_on: + group: codex-runners + labels: codex-windows-x64 + - runner: windows-arm64 + target: aarch64-pc-windows-msvc + profile: release + runs_on: + group: codex-runners + labels: codex-windows-arm64 + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Install Linux build dependencies + if: ${{ runner.os == 'Linux' }} + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -y + packages=(pkg-config libcap-dev) + if [[ "${{ matrix.target }}" == 'x86_64-unknown-linux-musl' || "${{ matrix.target }}" == 'aarch64-unknown-linux-musl' ]]; then + packages+=(libubsan1) + fi + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${packages[@]}" + fi + - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + with: + targets: ${{ matrix.target }} + components: clippy + + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} + name: Use hermetic Cargo home (musl) + shell: bash + run: | + set -euo pipefail + cargo_home="${GITHUB_WORKSPACE}/.cargo-home" + mkdir -p "${cargo_home}/bin" + echo "CARGO_HOME=${cargo_home}" >> "$GITHUB_ENV" + echo "${cargo_home}/bin" >> "$GITHUB_PATH" + : > "${cargo_home}/config.toml" + + - name: Compute lockfile hash + id: lockhash + working-directory: codex-rs + shell: bash + run: | + set -euo pipefail + echo "hash=$(sha256sum Cargo.lock | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + echo "toolchain_hash=$(sha256sum rust-toolchain.toml | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + + # Explicit cache restore: split cargo home vs target, so we can + # avoid caching the large target dir on the gnu-dev job. + - name: Restore cargo home cache + id: cache_cargo_home_restore + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + ${{ github.workspace }}/.cargo-home/bin/ + ${{ github.workspace }}/.cargo-home/registry/index/ + ${{ github.workspace }}/.cargo-home/registry/cache/ + ${{ github.workspace }}/.cargo-home/git/db/ + key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }} + restore-keys: | + cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- + + # Install and restore sccache cache + - name: Install sccache + if: ${{ env.USE_SCCACHE == 'true' }} + uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 + with: + tool: sccache + version: 0.7.5 + + - name: Configure sccache backend + if: ${{ env.USE_SCCACHE == 'true' }} + shell: bash + run: | + set -euo pipefail + if [[ -n "${ACTIONS_CACHE_URL:-}" && -n "${ACTIONS_RUNTIME_TOKEN:-}" ]]; then + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "Using sccache GitHub backend" + else + echo "SCCACHE_GHA_ENABLED=false" >> "$GITHUB_ENV" + echo "SCCACHE_DIR=${{ github.workspace }}/.sccache" >> "$GITHUB_ENV" + echo "Using sccache local disk + actions/cache fallback" + fi + + - name: Enable sccache wrapper + if: ${{ env.USE_SCCACHE == 'true' }} + shell: bash + run: echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" + + - name: Restore sccache cache (fallback) + if: ${{ env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' }} + id: cache_sccache_restore + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + with: + path: ${{ github.workspace }}/.sccache/ + key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }} + restore-keys: | + sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}- + sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- + + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} + name: Disable sccache wrapper (musl) + shell: bash + run: | + set -euo pipefail + echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV" + echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} + name: Prepare APT cache directories (musl) + shell: bash + run: | + set -euo pipefail + sudo mkdir -p /var/cache/apt/archives /var/lib/apt/lists + sudo chown -R "$USER:$USER" /var/cache/apt /var/lib/apt/lists + + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} + name: Restore APT cache (musl) + id: cache_apt_restore + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + with: + path: | + /var/cache/apt + key: apt-${{ matrix.runner }}-${{ matrix.target }}-v1 + + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} + name: Install Zig + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 + with: + version: 0.14.0 + + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} + name: Install musl build tools + env: + DEBIAN_FRONTEND: noninteractive + TARGET: ${{ matrix.target }} + APT_UPDATE_ARGS: -o Acquire::Retries=3 + APT_INSTALL_ARGS: --no-install-recommends + shell: bash + run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" + + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} + name: Configure rustc UBSan wrapper (musl host) + shell: bash + run: | + set -euo pipefail + ubsan="" + if command -v ldconfig >/dev/null 2>&1; then + ubsan="$(ldconfig -p | grep -m1 'libubsan\.so\.1' | sed -E 's/.*=> (.*)$/\1/')" + fi + wrapper_root="${RUNNER_TEMP:-/tmp}" + wrapper="${wrapper_root}/rustc-ubsan-wrapper" + cat > "${wrapper}" <> "$GITHUB_ENV" + echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} + name: Clear sanitizer flags (musl) + shell: bash + run: | + set -euo pipefail + # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. + echo "RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "RUSTDOCFLAGS=" >> "$GITHUB_ENV" + # Override any runner-level Cargo config rustflags as well. + echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" + + sanitize_flags() { + local input="$1" + input="${input//-fsanitize=undefined/}" + input="${input//-fno-sanitize-recover=undefined/}" + input="${input//-fno-sanitize-trap=undefined/}" + echo "$input" + } + + cflags="$(sanitize_flags "${CFLAGS-}")" + cxxflags="$(sanitize_flags "${CXXFLAGS-}")" + echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" + echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" + + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }} + name: Configure musl rusty_v8 artifact overrides + env: + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + version="$(python3 "${GITHUB_WORKSPACE}/.github/scripts/rusty_v8_bazel.py" resolved-v8-crate-version)" + release_tag="rusty-v8-v${version}" + base_url="https://github.com/openai/codex/releases/download/${release_tag}" + archive="https://github.com/openai/codex/releases/download/rusty-v8-v${version}/librusty_v8_release_${TARGET}.a.gz" + binding_dir="${RUNNER_TEMP}/rusty_v8" + binding_path="${binding_dir}/src_binding_release_${TARGET}.rs" + mkdir -p "${binding_dir}" + curl -fsSL "${base_url}/src_binding_release_${TARGET}.rs" -o "${binding_path}" + echo "RUSTY_V8_ARCHIVE=${archive}" >> "$GITHUB_ENV" + echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "$GITHUB_ENV" + + - name: Install cargo-chef + if: ${{ matrix.profile == 'release' }} + uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 + with: + tool: cargo-chef + version: 0.1.71 + + - name: Pre-warm dependency cache (cargo-chef) + if: ${{ matrix.profile == 'release' }} + shell: bash + run: | + set -euo pipefail + RECIPE="${RUNNER_TEMP}/chef-recipe.json" + cargo chef prepare --recipe-path "$RECIPE" + cargo chef cook --recipe-path "$RECIPE" --target ${{ matrix.target }} --release + + - name: cargo clippy + run: cargo clippy --target ${{ matrix.target }} --tests --profile ${{ matrix.profile }} --timings -- -D warnings + + - name: Upload Cargo timings (clippy) + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: cargo-timings-rust-ci-clippy-${{ matrix.target }}-${{ matrix.profile }} + path: codex-rs/target/**/cargo-timings/cargo-timing.html + if-no-files-found: warn + + # Save caches explicitly; make non-fatal so cache packaging + # never fails the overall job. Only save when key wasn't hit. + - name: Save cargo home cache + if: always() && !cancelled() && steps.cache_cargo_home_restore.outputs.cache-hit != 'true' + continue-on-error: true + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + ${{ github.workspace }}/.cargo-home/bin/ + ${{ github.workspace }}/.cargo-home/registry/index/ + ${{ github.workspace }}/.cargo-home/registry/cache/ + ${{ github.workspace }}/.cargo-home/git/db/ + key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }} + + - name: Save sccache cache (fallback) + if: always() && !cancelled() && env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' + continue-on-error: true + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + with: + path: ${{ github.workspace }}/.sccache/ + key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }} + + - name: sccache stats + if: always() && env.USE_SCCACHE == 'true' + continue-on-error: true + run: sccache --show-stats || true + + - name: sccache summary + if: always() && env.USE_SCCACHE == 'true' + shell: bash + run: | + { + echo "### sccache stats — ${{ matrix.target }} (${{ matrix.profile }})"; + echo; + echo '```'; + sccache --show-stats || true; + echo '```'; + } >> "$GITHUB_STEP_SUMMARY" + + - name: Save APT cache (musl) + if: always() && !cancelled() && (matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl') && steps.cache_apt_restore.outputs.cache-hit != 'true' + continue-on-error: true + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + with: + path: | + /var/cache/apt + key: apt-${{ matrix.runner }}-${{ matrix.target }}-v1 + + tests: + name: Tests — ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.remote_env == 'true' && ' (remote)' || '' }} + runs-on: ${{ matrix.runs_on || matrix.runner }} + # Perhaps we can bring this back down to 30m once we finish the cutover + # from tui_app_server/ to tui/. Incidentally, windows-arm64 was the main + # offender for exceeding the timeout. + timeout-minutes: 45 + defaults: + run: + working-directory: codex-rs + env: + # Speed up repeated builds across CI runs by caching compiled objects, except on + # arm64 macOS runners cross-targeting x86_64 where ring/cc-rs can produce + # mixed-architecture archives under sccache. + USE_SCCACHE: ${{ (startsWith(matrix.runner, 'windows') || (matrix.runner == 'macos-15-xlarge' && matrix.target == 'x86_64-apple-darwin')) && 'false' || 'true' }} + CARGO_INCREMENTAL: "0" + SCCACHE_CACHE_SIZE: 10G + + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15-xlarge + target: aarch64-apple-darwin + profile: dev + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + profile: dev + remote_env: "true" + runs_on: + group: codex-runners + labels: codex-linux-x64 + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + profile: dev + runs_on: + group: codex-runners + labels: codex-linux-arm64 + - runner: windows-x64 + target: x86_64-pc-windows-msvc + profile: dev + runs_on: + group: codex-runners + labels: codex-windows-x64 + - runner: windows-arm64 + target: aarch64-pc-windows-msvc + profile: dev + runs_on: + group: codex-runners + labels: codex-windows-arm64 + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Set up Node.js for js_repl tests + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version-file: codex-rs/node-version.txt + - name: Install Linux build dependencies + if: ${{ runner.os == 'Linux' }} + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -y + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev + fi + + # Some integration tests rely on DotSlash being installed. + # See https://github.com/openai/codex/pull/7617. + - name: Install DotSlash + uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 + + - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + with: + targets: ${{ matrix.target }} + + - name: Compute lockfile hash + id: lockhash + working-directory: codex-rs + shell: bash + run: | + set -euo pipefail + echo "hash=$(sha256sum Cargo.lock | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + echo "toolchain_hash=$(sha256sum rust-toolchain.toml | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + + - name: Restore cargo home cache + id: cache_cargo_home_restore + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }} + restore-keys: | + cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- + + - name: Install sccache + if: ${{ env.USE_SCCACHE == 'true' }} + uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 + with: + tool: sccache + version: 0.7.5 + + - name: Configure sccache backend + if: ${{ env.USE_SCCACHE == 'true' }} + shell: bash + run: | + set -euo pipefail + if [[ -n "${ACTIONS_CACHE_URL:-}" && -n "${ACTIONS_RUNTIME_TOKEN:-}" ]]; then + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "Using sccache GitHub backend" + else + echo "SCCACHE_GHA_ENABLED=false" >> "$GITHUB_ENV" + echo "SCCACHE_DIR=${{ github.workspace }}/.sccache" >> "$GITHUB_ENV" + echo "Using sccache local disk + actions/cache fallback" + fi + + - name: Enable sccache wrapper + if: ${{ env.USE_SCCACHE == 'true' }} + shell: bash + run: echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" + + - name: Restore sccache cache (fallback) + if: ${{ env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' }} + id: cache_sccache_restore + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + with: + path: ${{ github.workspace }}/.sccache/ + key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }} + restore-keys: | + sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}- + sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- + + - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 + with: + tool: nextest + version: 0.9.103 + + - name: Enable unprivileged user namespaces (Linux) + if: runner.os == 'Linux' + run: | + # Required for bubblewrap to work on Linux CI runners. + sudo sysctl -w kernel.unprivileged_userns_clone=1 + # Ubuntu 24.04+ can additionally gate unprivileged user namespaces + # behind AppArmor. + if sudo sysctl -a 2>/dev/null | grep -q '^kernel.apparmor_restrict_unprivileged_userns'; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + + - name: Set up remote test env (Docker) + if: ${{ runner.os == 'Linux' && matrix.remote_env == 'true' }} + shell: bash + run: | + set -euo pipefail + export CODEX_TEST_REMOTE_ENV_CONTAINER_NAME=codex-remote-test-env + source "${GITHUB_WORKSPACE}/scripts/test-remote-env.sh" + echo "CODEX_TEST_REMOTE_ENV=${CODEX_TEST_REMOTE_ENV}" >> "$GITHUB_ENV" + + - name: tests + id: test + run: cargo nextest run --no-fail-fast --target ${{ matrix.target }} --cargo-profile ci-test --timings + env: + RUST_BACKTRACE: 1 + NEXTEST_STATUS_LEVEL: leak + + - name: Upload Cargo timings (nextest) + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: cargo-timings-rust-ci-nextest-${{ matrix.target }}-${{ matrix.profile }} + path: codex-rs/target/**/cargo-timings/cargo-timing.html + if-no-files-found: warn + + - name: Save cargo home cache + if: always() && !cancelled() && steps.cache_cargo_home_restore.outputs.cache-hit != 'true' + continue-on-error: true + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }} + + - name: Save sccache cache (fallback) + if: always() && !cancelled() && env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' + continue-on-error: true + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + with: + path: ${{ github.workspace }}/.sccache/ + key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }} + + - name: sccache stats + if: always() && env.USE_SCCACHE == 'true' + continue-on-error: true + run: sccache --show-stats || true + + - name: sccache summary + if: always() && env.USE_SCCACHE == 'true' + shell: bash + run: | + { + echo "### sccache stats — ${{ matrix.target }} (tests)"; + echo; + echo '```'; + sccache --show-stats || true; + echo '```'; + } >> "$GITHUB_STEP_SUMMARY" + + - name: Tear down remote test env + if: ${{ always() && runner.os == 'Linux' && matrix.remote_env == 'true' }} + shell: bash + run: | + set +e + if [[ "${{ steps.test.outcome }}" != "success" ]]; then + docker logs codex-remote-test-env || true + fi + docker rm -f codex-remote-test-env >/dev/null 2>&1 || true + + - name: verify tests passed + if: steps.test.outcome == 'failure' + run: | + echo "Tests failed. See logs for details." + exit 1 + + # --- Gatherer job for the full post-merge workflow -------------------------- + results: + name: Full CI results + needs: + [ + general, + cargo_shear, + argument_comment_lint_package, + argument_comment_lint_prebuilt, + lint_build, + tests, + ] + if: always() + runs-on: ubuntu-24.04 + steps: + - name: Summarize + shell: bash + run: | + echo "argpkg : ${{ needs.argument_comment_lint_package.result }}" + echo "arglint: ${{ needs.argument_comment_lint_prebuilt.result }}" + echo "general: ${{ needs.general.result }}" + echo "shear : ${{ needs.cargo_shear.result }}" + echo "lint : ${{ needs.lint_build.result }}" + echo "tests : ${{ needs.tests.result }}" + [[ '${{ needs.argument_comment_lint_package.result }}' == 'success' ]] || { echo 'argument_comment_lint_package failed'; exit 1; } + [[ '${{ needs.argument_comment_lint_prebuilt.result }}' == 'success' ]] || { echo 'argument_comment_lint_prebuilt failed'; exit 1; } + [[ '${{ needs.general.result }}' == 'success' ]] || { echo 'general failed'; exit 1; } + [[ '${{ needs.cargo_shear.result }}' == 'success' ]] || { echo 'cargo_shear failed'; exit 1; } + [[ '${{ needs.lint_build.result }}' == 'success' ]] || { echo 'lint_build failed'; exit 1; } + [[ '${{ needs.tests.result }}' == 'success' ]] || { echo 'tests failed'; exit 1; } + + - name: sccache summary note + if: always() + run: | + echo "Per-job sccache stats are attached to each matrix job's Step Summary." diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index c203e2b742..3a9eadc8be 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -1,15 +1,10 @@ name: rust-ci on: pull_request: {} - push: - branches: - - main workflow_dispatch: -# CI builds in debug (dev) for faster signal. - jobs: - # --- Detect what changed to detect which tests to run (always runs) ------------------------------------- + # --- Detect what changed so the fast PR workflow only runs relevant jobs ---- changed: name: Detect changed areas runs-on: ubuntu-24.04 @@ -19,7 +14,7 @@ jobs: codex: ${{ steps.detect.outputs.codex }} workflows: ${{ steps.detect.outputs.workflows }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 - name: Detect changed paths (no external action) @@ -33,11 +28,10 @@ jobs: HEAD_SHA='${{ github.event.pull_request.head.sha }}' echo "Base SHA: $BASE_SHA" echo "Head SHA: $HEAD_SHA" - # List files changed between base and PR head mapfile -t files < <(git diff --name-only --no-renames "$BASE_SHA" "$HEAD_SHA") else - # On push / manual runs, default to running everything - files=("codex-rs/force" ".github/force") + # On manual runs, default to the full fast-PR bundle. + files=("codex-rs/force" "tools/argument-comment-lint/force" ".github/force") fi codex=false @@ -47,7 +41,7 @@ jobs: for f in "${files[@]}"; do [[ $f == codex-rs/* ]] && codex=true [[ $f == codex-rs/* || $f == tools/argument-comment-lint/* || $f == justfile ]] && argument_comment_lint=true - [[ $f == tools/argument-comment-lint/* || $f == .github/workflows/rust-ci.yml ]] && argument_comment_lint_package=true + [[ $f == tools/argument-comment-lint/* || $f == .github/workflows/rust-ci.yml || $f == .github/workflows/rust-ci-full.yml ]] && argument_comment_lint_package=true [[ $f == .github/* ]] && workflows=true done @@ -56,18 +50,18 @@ jobs: echo "codex=$codex" >> "$GITHUB_OUTPUT" echo "workflows=$workflows" >> "$GITHUB_OUTPUT" - # --- CI that doesn't need specific targets --------------------------------- + # --- Fast Cargo-native PR checks ------------------------------------------- general: name: Format / etc runs-on: ubuntu-24.04 needs: changed - if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }} + if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' }} defaults: run: working-directory: codex-rs steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 with: components: rustfmt - name: cargo fmt @@ -77,13 +71,13 @@ jobs: name: cargo shear runs-on: ubuntu-24.04 needs: changed - if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }} + if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' }} defaults: run: working-directory: codex-rs steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 with: tool: cargo-shear @@ -95,16 +89,23 @@ jobs: name: Argument comment lint package runs-on: ubuntu-24.04 needs: changed - if: ${{ needs.changed.outputs.argument_comment_lint_package == 'true' || github.event_name == 'push' }} + if: ${{ needs.changed.outputs.argument_comment_lint_package == 'true' }} steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.93.0 - with: - toolchain: nightly-2025-09-18 - components: llvm-tools-preview, rustc-dev, rust-src + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - name: Install nightly argument-comment-lint toolchain + shell: bash + run: | + rustup toolchain install nightly-2025-09-18 \ + --profile minimal \ + --component llvm-tools-preview \ + --component rustc-dev \ + --component rust-src \ + --no-self-update + rustup default nightly-2025-09-18 - name: Cache cargo-dylint tooling id: cargo_dylint_cache - uses: actions/cache@v5 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 with: path: | ~/.cargo/bin/cargo-dylint @@ -112,12 +113,14 @@ jobs: ~/.cargo/registry/index ~/.cargo/registry/cache ~/.cargo/git/db - key: argument-comment-lint-${{ runner.os }}-${{ hashFiles('tools/argument-comment-lint/Cargo.lock', 'tools/argument-comment-lint/rust-toolchain', '.github/workflows/rust-ci.yml') }} + key: argument-comment-lint-${{ runner.os }}-${{ hashFiles('tools/argument-comment-lint/Cargo.lock', 'tools/argument-comment-lint/rust-toolchain', '.github/workflows/rust-ci.yml', '.github/workflows/rust-ci-full.yml') }} - name: Install cargo-dylint tooling if: ${{ steps.cargo_dylint_cache.outputs.cache-hit != 'true' }} run: cargo install --locked cargo-dylint dylint-link - - name: Check source wrapper syntax - run: bash -n tools/argument-comment-lint/run.sh + - name: Check Python wrapper syntax + run: python3 -m py_compile tools/argument-comment-lint/wrapper_common.py tools/argument-comment-lint/run.py tools/argument-comment-lint/run-prebuilt-linter.py tools/argument-comment-lint/test_wrapper_common.py + - name: Test Python wrapper helpers + run: python3 -m unittest discover -s tools/argument-comment-lint -p 'test_*.py' - name: Test argument comment lint package working-directory: tools/argument-comment-lint run: cargo test @@ -125,651 +128,63 @@ jobs: argument_comment_lint_prebuilt: name: Argument comment lint - ${{ matrix.name }} runs-on: ${{ matrix.runs_on || matrix.runner }} + timeout-minutes: ${{ matrix.timeout_minutes }} needs: changed - if: ${{ needs.changed.outputs.argument_comment_lint == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }} + if: ${{ needs.changed.outputs.argument_comment_lint == 'true' || needs.changed.outputs.workflows == 'true' }} strategy: fail-fast: false matrix: include: - name: Linux runner: ubuntu-24.04 + timeout_minutes: 30 - name: macOS runner: macos-15-xlarge + timeout_minutes: 30 - name: Windows runner: windows-x64 + timeout_minutes: 30 runs_on: group: codex-runners labels: codex-windows-x64 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: ./.github/actions/setup-bazel-ci + with: + target: ${{ runner.os }} + install-test-prereqs: true - name: Install Linux sandbox build dependencies if: ${{ runner.os == 'Linux' }} shell: bash run: | sudo DEBIAN_FRONTEND=noninteractive apt-get update sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - - uses: dtolnay/rust-toolchain@1.93.0 - with: - toolchain: nightly-2025-09-18 - components: llvm-tools-preview, rustc-dev, rust-src - - uses: facebook/install-dotslash@v2 - - name: Run argument comment lint on codex-rs - shell: bash - run: ./tools/argument-comment-lint/run-prebuilt-linter.sh - - # --- CI to validate on different os/targets -------------------------------- - lint_build: - name: Lint/Build — ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.profile == 'release' && ' (release)' || '' }} - runs-on: ${{ matrix.runs_on || matrix.runner }} - timeout-minutes: 30 - needs: changed - # Keep job-level if to avoid spinning up runners when not needed - if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }} - defaults: - run: - working-directory: codex-rs - env: - # Speed up repeated builds across CI runs by caching compiled objects, except on - # arm64 macOS runners cross-targeting x86_64 where ring/cc-rs can produce - # mixed-architecture archives under sccache. - USE_SCCACHE: ${{ (startsWith(matrix.runner, 'windows') || (matrix.runner == 'macos-15-xlarge' && matrix.target == 'x86_64-apple-darwin')) && 'false' || 'true' }} - CARGO_INCREMENTAL: "0" - SCCACHE_CACHE_SIZE: 10G - # In rust-ci, representative release-profile checks use thin LTO for faster feedback. - CARGO_PROFILE_RELEASE_LTO: ${{ matrix.profile == 'release' && 'thin' || 'fat' }} - - strategy: - fail-fast: false - matrix: - include: - - runner: macos-15-xlarge - target: aarch64-apple-darwin - profile: dev - - runner: macos-15-xlarge - target: x86_64-apple-darwin - profile: dev - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - profile: dev - runs_on: - group: codex-runners - labels: codex-linux-x64 - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-gnu - profile: dev - runs_on: - group: codex-runners - labels: codex-linux-x64 - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - profile: dev - runs_on: - group: codex-runners - labels: codex-linux-arm64 - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-gnu - profile: dev - runs_on: - group: codex-runners - labels: codex-linux-arm64 - - runner: windows-x64 - target: x86_64-pc-windows-msvc - profile: dev - runs_on: - group: codex-runners - labels: codex-windows-x64 - - runner: windows-arm64 - target: aarch64-pc-windows-msvc - profile: dev - runs_on: - group: codex-runners - labels: codex-windows-arm64 - - # Also run representative release builds on Mac and Linux because - # there could be release-only build errors we want to catch. - # Hopefully this also pre-populates the build cache to speed up - # releases. - - runner: macos-15-xlarge - target: aarch64-apple-darwin - profile: release - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - profile: release - runs_on: - group: codex-runners - labels: codex-linux-x64 - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - profile: release - runs_on: - group: codex-runners - labels: codex-linux-arm64 - - runner: windows-x64 - target: x86_64-pc-windows-msvc - profile: release - runs_on: - group: codex-runners - labels: codex-windows-x64 - - runner: windows-arm64 - target: aarch64-pc-windows-msvc - profile: release - runs_on: - group: codex-runners - labels: codex-windows-arm64 - - steps: - - uses: actions/checkout@v6 - - name: Install Linux build dependencies - if: ${{ runner.os == 'Linux' }} - shell: bash - run: | - set -euo pipefail - if command -v apt-get >/dev/null 2>&1; then - sudo apt-get update -y - packages=(pkg-config libcap-dev) - if [[ "${{ matrix.target }}" == 'x86_64-unknown-linux-musl' || "${{ matrix.target }}" == 'aarch64-unknown-linux-musl' ]]; then - packages+=(libubsan1) - fi - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${packages[@]}" - fi - - uses: dtolnay/rust-toolchain@1.93.0 - with: - targets: ${{ matrix.target }} - components: clippy - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Use hermetic Cargo home (musl) - shell: bash - run: | - set -euo pipefail - cargo_home="${GITHUB_WORKSPACE}/.cargo-home" - mkdir -p "${cargo_home}/bin" - echo "CARGO_HOME=${cargo_home}" >> "$GITHUB_ENV" - echo "${cargo_home}/bin" >> "$GITHUB_PATH" - : > "${cargo_home}/config.toml" - - - name: Compute lockfile hash - id: lockhash - working-directory: codex-rs - shell: bash - run: | - set -euo pipefail - echo "hash=$(sha256sum Cargo.lock | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - echo "toolchain_hash=$(sha256sum rust-toolchain.toml | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - - # Explicit cache restore: split cargo home vs target, so we can - # avoid caching the large target dir on the gnu-dev job. - - name: Restore cargo home cache - id: cache_cargo_home_restore - uses: actions/cache/restore@v5 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - ${{ github.workspace }}/.cargo-home/bin/ - ${{ github.workspace }}/.cargo-home/registry/index/ - ${{ github.workspace }}/.cargo-home/registry/cache/ - ${{ github.workspace }}/.cargo-home/git/db/ - key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }} - restore-keys: | - cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- - - # Install and restore sccache cache - - name: Install sccache - if: ${{ env.USE_SCCACHE == 'true' }} - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 - with: - tool: sccache - version: 0.7.5 - - - name: Configure sccache backend - if: ${{ env.USE_SCCACHE == 'true' }} - shell: bash - run: | - set -euo pipefail - if [[ -n "${ACTIONS_CACHE_URL:-}" && -n "${ACTIONS_RUNTIME_TOKEN:-}" ]]; then - echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" - echo "Using sccache GitHub backend" - else - echo "SCCACHE_GHA_ENABLED=false" >> "$GITHUB_ENV" - echo "SCCACHE_DIR=${{ github.workspace }}/.sccache" >> "$GITHUB_ENV" - echo "Using sccache local disk + actions/cache fallback" - fi - - - name: Enable sccache wrapper - if: ${{ env.USE_SCCACHE == 'true' }} - shell: bash - run: echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" - - - name: Restore sccache cache (fallback) - if: ${{ env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' }} - id: cache_sccache_restore - uses: actions/cache/restore@v5 - with: - path: ${{ github.workspace }}/.sccache/ - key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }} - restore-keys: | - sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}- - sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Disable sccache wrapper (musl) - shell: bash - run: | - set -euo pipefail - echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV" - echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV" - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Prepare APT cache directories (musl) - shell: bash - run: | - set -euo pipefail - sudo mkdir -p /var/cache/apt/archives /var/lib/apt/lists - sudo chown -R "$USER:$USER" /var/cache/apt /var/lib/apt/lists - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Restore APT cache (musl) - id: cache_apt_restore - uses: actions/cache/restore@v5 - with: - path: | - /var/cache/apt - key: apt-${{ matrix.runner }}-${{ matrix.target }}-v1 - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Install Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 - with: - version: 0.14.0 - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Install musl build tools + - name: Run argument comment lint on codex-rs via Bazel + if: ${{ runner.os != 'Windows' }} env: - DEBIAN_FRONTEND: noninteractive - TARGET: ${{ matrix.target }} - APT_UPDATE_ARGS: -o Acquire::Retries=3 - APT_INSTALL_ARGS: --no-install-recommends - shell: bash - run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Configure rustc UBSan wrapper (musl host) + BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} shell: bash run: | - set -euo pipefail - ubsan="" - if command -v ldconfig >/dev/null 2>&1; then - ubsan="$(ldconfig -p | grep -m1 'libubsan\.so\.1' | sed -E 's/.*=> (.*)$/\1/')" - fi - wrapper_root="${RUNNER_TEMP:-/tmp}" - wrapper="${wrapper_root}/rustc-ubsan-wrapper" - cat > "${wrapper}" <> "$GITHUB_ENV" - echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV" - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Clear sanitizer flags (musl) - shell: bash - run: | - set -euo pipefail - # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. - echo "RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "RUSTDOCFLAGS=" >> "$GITHUB_ENV" - # Override any runner-level Cargo config rustflags as well. - echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" - - sanitize_flags() { - local input="$1" - input="${input//-fsanitize=undefined/}" - input="${input//-fno-sanitize-recover=undefined/}" - input="${input//-fno-sanitize-trap=undefined/}" - echo "$input" - } - - cflags="$(sanitize_flags "${CFLAGS-}")" - cxxflags="$(sanitize_flags "${CXXFLAGS-}")" - echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" - echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }} - name: Configure musl rusty_v8 artifact overrides + bazel_targets="$(./tools/argument-comment-lint/list-bazel-targets.sh)" + ./.github/scripts/run-bazel-ci.sh \ + -- \ + build \ + --config=argument-comment-lint \ + --keep_going \ + --build_metadata=COMMIT_SHA=${GITHUB_SHA} \ + -- \ + ${bazel_targets} + - name: Run argument comment lint on codex-rs via Bazel + if: ${{ runner.os == 'Windows' }} env: - TARGET: ${{ matrix.target }} + BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} shell: bash run: | - set -euo pipefail - version="$(python3 "${GITHUB_WORKSPACE}/.github/scripts/rusty_v8_bazel.py" resolved-v8-crate-version)" - release_tag="rusty-v8-v${version}" - base_url="https://github.com/openai/codex/releases/download/${release_tag}" - archive="https://github.com/openai/codex/releases/download/rusty-v8-v${version}/librusty_v8_release_${TARGET}.a.gz" - binding_dir="${RUNNER_TEMP}/rusty_v8" - binding_path="${binding_dir}/src_binding_release_${TARGET}.rs" - mkdir -p "${binding_dir}" - curl -fsSL "${base_url}/src_binding_release_${TARGET}.rs" -o "${binding_path}" - echo "RUSTY_V8_ARCHIVE=${archive}" >> "$GITHUB_ENV" - echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "$GITHUB_ENV" - - - name: Install cargo-chef - if: ${{ matrix.profile == 'release' }} - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 - with: - tool: cargo-chef - version: 0.1.71 - - - name: Pre-warm dependency cache (cargo-chef) - if: ${{ matrix.profile == 'release' }} - shell: bash - run: | - set -euo pipefail - RECIPE="${RUNNER_TEMP}/chef-recipe.json" - cargo chef prepare --recipe-path "$RECIPE" - cargo chef cook --recipe-path "$RECIPE" --target ${{ matrix.target }} --release --all-features - - - name: cargo clippy - run: cargo clippy --target ${{ matrix.target }} --all-features --tests --profile ${{ matrix.profile }} --timings -- -D warnings - - - name: Upload Cargo timings (clippy) - if: always() - uses: actions/upload-artifact@v7 - with: - name: cargo-timings-rust-ci-clippy-${{ matrix.target }}-${{ matrix.profile }} - path: codex-rs/target/**/cargo-timings/cargo-timing.html - if-no-files-found: warn - - # Save caches explicitly; make non-fatal so cache packaging - # never fails the overall job. Only save when key wasn't hit. - - name: Save cargo home cache - if: always() && !cancelled() && steps.cache_cargo_home_restore.outputs.cache-hit != 'true' - continue-on-error: true - uses: actions/cache/save@v5 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - ${{ github.workspace }}/.cargo-home/bin/ - ${{ github.workspace }}/.cargo-home/registry/index/ - ${{ github.workspace }}/.cargo-home/registry/cache/ - ${{ github.workspace }}/.cargo-home/git/db/ - key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }} - - - name: Save sccache cache (fallback) - if: always() && !cancelled() && env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' - continue-on-error: true - uses: actions/cache/save@v5 - with: - path: ${{ github.workspace }}/.sccache/ - key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }} - - - name: sccache stats - if: always() && env.USE_SCCACHE == 'true' - continue-on-error: true - run: sccache --show-stats || true - - - name: sccache summary - if: always() && env.USE_SCCACHE == 'true' - shell: bash - run: | - { - echo "### sccache stats — ${{ matrix.target }} (${{ matrix.profile }})"; - echo; - echo '```'; - sccache --show-stats || true; - echo '```'; - } >> "$GITHUB_STEP_SUMMARY" - - - name: Save APT cache (musl) - if: always() && !cancelled() && (matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl') && steps.cache_apt_restore.outputs.cache-hit != 'true' - continue-on-error: true - uses: actions/cache/save@v5 - with: - path: | - /var/cache/apt - key: apt-${{ matrix.runner }}-${{ matrix.target }}-v1 - - tests: - name: Tests — ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.remote_env == 'true' && ' (remote)' || '' }} - runs-on: ${{ matrix.runs_on || matrix.runner }} - timeout-minutes: ${{ matrix.runner == 'windows-arm64' && 35 || 30 }} - needs: changed - if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }} - defaults: - run: - working-directory: codex-rs - env: - # Speed up repeated builds across CI runs by caching compiled objects, except on - # arm64 macOS runners cross-targeting x86_64 where ring/cc-rs can produce - # mixed-architecture archives under sccache. - USE_SCCACHE: ${{ (startsWith(matrix.runner, 'windows') || (matrix.runner == 'macos-15-xlarge' && matrix.target == 'x86_64-apple-darwin')) && 'false' || 'true' }} - CARGO_INCREMENTAL: "0" - SCCACHE_CACHE_SIZE: 10G - - strategy: - fail-fast: false - matrix: - include: - - runner: macos-15-xlarge - target: aarch64-apple-darwin - profile: dev - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-gnu - profile: dev - remote_env: "true" - runs_on: - group: codex-runners - labels: codex-linux-x64 - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-gnu - profile: dev - runs_on: - group: codex-runners - labels: codex-linux-arm64 - - runner: windows-x64 - target: x86_64-pc-windows-msvc - profile: dev - runs_on: - group: codex-runners - labels: codex-windows-x64 - - runner: windows-arm64 - target: aarch64-pc-windows-msvc - profile: dev - runs_on: - group: codex-runners - labels: codex-windows-arm64 - - steps: - - uses: actions/checkout@v6 - - name: Set up Node.js for js_repl tests - uses: actions/setup-node@v6 - with: - node-version-file: codex-rs/node-version.txt - - name: Install Linux build dependencies - if: ${{ runner.os == 'Linux' }} - shell: bash - run: | - set -euo pipefail - if command -v apt-get >/dev/null 2>&1; then - sudo apt-get update -y - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - fi - - # Some integration tests rely on DotSlash being installed. - # See https://github.com/openai/codex/pull/7617. - - name: Install DotSlash - uses: facebook/install-dotslash@v2 - - - uses: dtolnay/rust-toolchain@1.93.0 - with: - targets: ${{ matrix.target }} - - - name: Compute lockfile hash - id: lockhash - working-directory: codex-rs - shell: bash - run: | - set -euo pipefail - echo "hash=$(sha256sum Cargo.lock | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - echo "toolchain_hash=$(sha256sum rust-toolchain.toml | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - - - name: Restore cargo home cache - id: cache_cargo_home_restore - uses: actions/cache/restore@v5 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }} - restore-keys: | - cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- - - - name: Install sccache - if: ${{ env.USE_SCCACHE == 'true' }} - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 - with: - tool: sccache - version: 0.7.5 - - - name: Configure sccache backend - if: ${{ env.USE_SCCACHE == 'true' }} - shell: bash - run: | - set -euo pipefail - if [[ -n "${ACTIONS_CACHE_URL:-}" && -n "${ACTIONS_RUNTIME_TOKEN:-}" ]]; then - echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" - echo "Using sccache GitHub backend" - else - echo "SCCACHE_GHA_ENABLED=false" >> "$GITHUB_ENV" - echo "SCCACHE_DIR=${{ github.workspace }}/.sccache" >> "$GITHUB_ENV" - echo "Using sccache local disk + actions/cache fallback" - fi - - - name: Enable sccache wrapper - if: ${{ env.USE_SCCACHE == 'true' }} - shell: bash - run: echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" - - - name: Restore sccache cache (fallback) - if: ${{ env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' }} - id: cache_sccache_restore - uses: actions/cache/restore@v5 - with: - path: ${{ github.workspace }}/.sccache/ - key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }} - restore-keys: | - sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}- - sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- - - - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 - with: - tool: nextest - version: 0.9.103 - - - name: Enable unprivileged user namespaces (Linux) - if: runner.os == 'Linux' - run: | - # Required for bubblewrap to work on Linux CI runners. - sudo sysctl -w kernel.unprivileged_userns_clone=1 - # Ubuntu 24.04+ can additionally gate unprivileged user namespaces - # behind AppArmor. - if sudo sysctl -a 2>/dev/null | grep -q '^kernel.apparmor_restrict_unprivileged_userns'; then - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - fi - - - name: Set up remote test env (Docker) - if: ${{ runner.os == 'Linux' && matrix.remote_env == 'true' }} - shell: bash - run: | - set -euo pipefail - export CODEX_TEST_REMOTE_ENV_CONTAINER_NAME=codex-remote-test-env - source "${GITHUB_WORKSPACE}/scripts/test-remote-env.sh" - echo "CODEX_TEST_REMOTE_ENV=${CODEX_TEST_REMOTE_ENV}" >> "$GITHUB_ENV" - - - name: tests - id: test - run: cargo nextest run --all-features --no-fail-fast --target ${{ matrix.target }} --cargo-profile ci-test --timings - env: - RUST_BACKTRACE: 1 - NEXTEST_STATUS_LEVEL: leak - - - name: Upload Cargo timings (nextest) - if: always() - uses: actions/upload-artifact@v7 - with: - name: cargo-timings-rust-ci-nextest-${{ matrix.target }}-${{ matrix.profile }} - path: codex-rs/target/**/cargo-timings/cargo-timing.html - if-no-files-found: warn - - - name: Save cargo home cache - if: always() && !cancelled() && steps.cache_cargo_home_restore.outputs.cache-hit != 'true' - continue-on-error: true - uses: actions/cache/save@v5 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }} - - - name: Save sccache cache (fallback) - if: always() && !cancelled() && env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' - continue-on-error: true - uses: actions/cache/save@v5 - with: - path: ${{ github.workspace }}/.sccache/ - key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }} - - - name: sccache stats - if: always() && env.USE_SCCACHE == 'true' - continue-on-error: true - run: sccache --show-stats || true - - - name: sccache summary - if: always() && env.USE_SCCACHE == 'true' - shell: bash - run: | - { - echo "### sccache stats — ${{ matrix.target }} (tests)"; - echo; - echo '```'; - sccache --show-stats || true; - echo '```'; - } >> "$GITHUB_STEP_SUMMARY" - - - name: Tear down remote test env - if: ${{ always() && runner.os == 'Linux' && matrix.remote_env == 'true' }} - shell: bash - run: | - set +e - if [[ "${{ steps.test.outcome }}" != "success" ]]; then - docker logs codex-remote-test-env || true - fi - docker rm -f codex-remote-test-env >/dev/null 2>&1 || true - - - name: verify tests passed - if: steps.test.outcome == 'failure' - run: | - echo "Tests failed. See logs for details." - exit 1 + ./.github/scripts/run-argument-comment-lint-bazel.sh \ + --config=argument-comment-lint \ + --platforms=//:local_windows \ + --keep_going \ + --build_metadata=COMMIT_SHA=${GITHUB_SHA} # --- Gatherer job that you mark as the ONLY required status ----------------- results: @@ -781,8 +196,6 @@ jobs: cargo_shear, argument_comment_lint_package, argument_comment_lint_prebuilt, - lint_build, - tests, ] if: always() runs-on: ubuntu-24.04 @@ -794,32 +207,23 @@ jobs: echo "arglint: ${{ needs.argument_comment_lint_prebuilt.result }}" echo "general: ${{ needs.general.result }}" echo "shear : ${{ needs.cargo_shear.result }}" - echo "lint : ${{ needs.lint_build.result }}" - echo "tests : ${{ needs.tests.result }}" # If nothing relevant changed (PR touching only root README, etc.), # declare success regardless of other jobs. - if [[ '${{ needs.changed.outputs.argument_comment_lint }}' != 'true' && '${{ needs.changed.outputs.codex }}' != 'true' && '${{ needs.changed.outputs.workflows }}' != 'true' && '${{ github.event_name }}' != 'push' ]]; then + if [[ '${{ needs.changed.outputs.argument_comment_lint }}' != 'true' && '${{ needs.changed.outputs.codex }}' != 'true' && '${{ needs.changed.outputs.workflows }}' != 'true' ]]; then echo 'No relevant changes -> CI not required.' exit 0 fi - if [[ '${{ needs.changed.outputs.argument_comment_lint_package }}' == 'true' || '${{ github.event_name }}' == 'push' ]]; then + if [[ '${{ needs.changed.outputs.argument_comment_lint_package }}' == 'true' ]]; then [[ '${{ needs.argument_comment_lint_package.result }}' == 'success' ]] || { echo 'argument_comment_lint_package failed'; exit 1; } fi - if [[ '${{ needs.changed.outputs.argument_comment_lint }}' == 'true' || '${{ needs.changed.outputs.workflows }}' == 'true' || '${{ github.event_name }}' == 'push' ]]; then + if [[ '${{ needs.changed.outputs.argument_comment_lint }}' == 'true' || '${{ needs.changed.outputs.workflows }}' == 'true' ]]; then [[ '${{ needs.argument_comment_lint_prebuilt.result }}' == 'success' ]] || { echo 'argument_comment_lint_prebuilt failed'; exit 1; } fi - if [[ '${{ needs.changed.outputs.codex }}' == 'true' || '${{ needs.changed.outputs.workflows }}' == 'true' || '${{ github.event_name }}' == 'push' ]]; then + if [[ '${{ needs.changed.outputs.codex }}' == 'true' || '${{ needs.changed.outputs.workflows }}' == 'true' ]]; then [[ '${{ needs.general.result }}' == 'success' ]] || { echo 'general failed'; exit 1; } [[ '${{ needs.cargo_shear.result }}' == 'success' ]] || { echo 'cargo_shear failed'; exit 1; } - [[ '${{ needs.lint_build.result }}' == 'success' ]] || { echo 'lint_build failed'; exit 1; } - [[ '${{ needs.tests.result }}' == 'success' ]] || { echo 'tests failed'; exit 1; } fi - - - name: sccache summary note - if: always() - run: | - echo "Per-job sccache stats are attached to each matrix job's Step Summary." diff --git a/.github/workflows/rust-release-argument-comment-lint.yml b/.github/workflows/rust-release-argument-comment-lint.yml index a0d12d6db4..a6e88d8d3e 100644 --- a/.github/workflows/rust-release-argument-comment-lint.yml +++ b/.github/workflows/rust-release-argument-comment-lint.yml @@ -53,9 +53,9 @@ jobs: labels: codex-windows-x64 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 with: toolchain: nightly-2025-09-18 targets: ${{ matrix.target }} @@ -97,7 +97,7 @@ jobs: (cd "${RUNNER_TEMP}" && tar -czf "$GITHUB_WORKSPACE/$archive_path" argument-comment-lint) fi - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: argument-comment-lint-${{ matrix.target }} path: dist/argument-comment-lint/${{ matrix.target }}/* diff --git a/.github/workflows/rust-release-prepare.yml b/.github/workflows/rust-release-prepare.yml index c9f11f54fc..b8151ae284 100644 --- a/.github/workflows/rust-release-prepare.yml +++ b/.github/workflows/rust-release-prepare.yml @@ -18,7 +18,7 @@ jobs: if: github.repository == 'openai/codex' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: main fetch-depth: 0 @@ -43,7 +43,7 @@ jobs: curl --http1.1 --fail --show-error --location "${headers[@]}" "${url}" | jq '.' > codex-rs/core/models.json - name: Open pull request (if changed) - uses: peter-evans/create-pull-request@v8 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8 with: commit-message: "Update models.json" title: "Update models.json" diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index f762fbc4b5..f1aee51911 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -67,7 +67,7 @@ jobs: labels: codex-windows-arm64 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Print runner specs (Windows) shell: powershell run: | @@ -82,7 +82,7 @@ jobs: Write-Host "Total RAM: $ramGiB GiB" Write-Host "Disk usage:" Get-PSDrive -PSProvider FileSystem | Format-Table -AutoSize Name, @{Name='Size(GB)';Expression={[math]::Round(($_.Used + $_.Free) / 1GB, 1)}}, @{Name='Free(GB)';Expression={[math]::Round($_.Free / 1GB, 1)}} - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 with: targets: ${{ matrix.target }} @@ -92,7 +92,7 @@ jobs: cargo build --target ${{ matrix.target }} --release --timings ${{ matrix.build_args }} - name: Upload Cargo timings - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: cargo-timings-rust-release-windows-${{ matrix.target }}-${{ matrix.bundle }} path: codex-rs/target/**/cargo-timings/cargo-timing.html @@ -112,7 +112,7 @@ jobs: fi - name: Upload Windows binaries - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: windows-binaries-${{ matrix.target }}-${{ matrix.bundle }} path: | @@ -147,16 +147,16 @@ jobs: labels: codex-windows-arm64 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Download prebuilt Windows primary binaries - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: windows-binaries-${{ matrix.target }}-primary path: codex-rs/target/${{ matrix.target }}/release - name: Download prebuilt Windows helper binaries - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: windows-binaries-${{ matrix.target }}-helpers path: codex-rs/target/${{ matrix.target }}/release @@ -193,7 +193,7 @@ jobs: cp target/${{ matrix.target }}/release/codex-command-runner.exe "$dest/codex-command-runner-${{ matrix.target }}.exe" - name: Install DotSlash - uses: facebook/install-dotslash@v2 + uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 - name: Compress artifacts shell: bash @@ -257,7 +257,7 @@ jobs: "${GITHUB_WORKSPACE}/.github/workflows/zstd" -T0 -19 "$dest/$base" done - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: ${{ matrix.target }} path: | diff --git a/.github/workflows/rust-release-zsh.yml b/.github/workflows/rust-release-zsh.yml index a0f71aa736..7ec49f9863 100644 --- a/.github/workflows/rust-release-zsh.yml +++ b/.github/workflows/rust-release-zsh.yml @@ -45,7 +45,7 @@ jobs: git \ libncursesw5-dev - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Build, smoke-test, and stage zsh artifact shell: bash @@ -53,7 +53,7 @@ jobs: "${GITHUB_WORKSPACE}/.github/scripts/build-zsh-release-artifact.sh" \ "dist/zsh/${{ matrix.target }}/${{ matrix.archive_name }}" - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: codex-zsh-${{ matrix.target }} path: dist/zsh/${{ matrix.target }}/* @@ -81,7 +81,7 @@ jobs: brew install autoconf fi - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Build, smoke-test, and stage zsh artifact shell: bash @@ -89,7 +89,7 @@ jobs: "${GITHUB_WORKSPACE}/.github/scripts/build-zsh-release-artifact.sh" \ "dist/zsh/${{ matrix.target }}/${{ matrix.archive_name }}" - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: codex-zsh-${{ matrix.target }} path: dist/zsh/${{ matrix.target }}/* diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 1ec9bd28ba..30e16c417d 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -19,8 +19,8 @@ jobs: tag-check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.92 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: dtolnay/rust-toolchain@c2b55edffaf41a251c410bb32bed22afefa800f1 # 1.92 - name: Validate tag matches Cargo.toml version shell: bash run: | @@ -79,7 +79,7 @@ jobs: target: aarch64-unknown-linux-gnu steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Print runner specs (Linux) if: ${{ runner.os == 'Linux' }} shell: bash @@ -125,7 +125,7 @@ jobs: sudo apt-get update -y sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 fi - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 with: targets: ${{ matrix.target }} @@ -235,7 +235,7 @@ jobs: cargo build --target ${{ matrix.target }} --release --timings --bin codex --bin codex-responses-api-proxy - name: Upload Cargo timings - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: cargo-timings-rust-release-${{ matrix.target }} path: codex-rs/target/**/cargo-timings/cargo-timing.html @@ -374,7 +374,7 @@ jobs: zstd -T0 -19 --rm "$dest/$base" done - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: ${{ matrix.target }} # Upload the per-binary .zst files as well as the new .tar.gz @@ -420,7 +420,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Generate release notes from tag commit message id: release_notes @@ -442,7 +442,7 @@ jobs: echo "path=${notes_path}" >> "${GITHUB_OUTPUT}" - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: path: dist @@ -492,12 +492,12 @@ jobs: fi - name: Setup pnpm - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 with: run_install: false - name: Setup Node.js for npm packaging - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: 22 @@ -505,7 +505,7 @@ jobs: run: pnpm install --frozen-lockfile # stage_npm_packages.py requires DotSlash when staging releases. - - uses: facebook/install-dotslash@v2 + - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 - name: Stage npm packages env: GH_TOKEN: ${{ github.token }} @@ -523,7 +523,7 @@ jobs: cp scripts/install/install.ps1 dist/install.ps1 - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2 with: name: ${{ steps.release_name.outputs.name }} tag_name: ${{ github.ref_name }} @@ -533,21 +533,21 @@ jobs: # (e.g. -alpha, -beta). Otherwise publish a normal release. prerelease: ${{ contains(steps.release_name.outputs.name, '-') }} - - uses: facebook/dotslash-publish-release@v2 + - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag: ${{ github.ref_name }} config: .github/dotslash-config.json - - uses: facebook/dotslash-publish-release@v2 + - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag: ${{ github.ref_name }} config: .github/dotslash-zsh-config.json - - uses: facebook/dotslash-publish-release@v2 + - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -582,16 +582,13 @@ jobs: steps: - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: - node-version: 22 + # Node 24 bundles npm >= 11.5.1, which trusted publishing requires. + node-version: 24 registry-url: "https://registry.npmjs.org" scope: "@openai" - # Trusted publishing requires npm CLI version 11.5.1 or later. - - name: Update npm - run: npm install -g npm@latest - - name: Download npm tarballs from release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/rusty-v8-release.yml b/.github/workflows/rusty-v8-release.yml index bb191b88cb..d06fe0ae88 100644 --- a/.github/workflows/rusty-v8-release.yml +++ b/.github/workflows/rusty-v8-release.yml @@ -25,10 +25,10 @@ jobs: v8_version: ${{ steps.v8_version.outputs.version }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: "3.12" @@ -75,13 +75,13 @@ jobs: target: aarch64-unknown-linux-musl steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Bazel - uses: bazelbuild/setup-bazelisk@v3 + uses: bazelbuild/setup-bazelisk@6ecf4fd8b7d1f9721785f1dd656a689acf9add47 # v3 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: "3.12" @@ -116,8 +116,8 @@ jobs: bazel \ --noexperimental_remote_repo_contents_cache \ - --bazelrc=.github/workflows/v8-ci.bazelrc \ "${bazel_args[@]}" \ + --config=ci-v8 \ "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - name: Stage release pair @@ -135,7 +135,7 @@ jobs: --output-dir "dist/${TARGET}" - name: Upload staged musl artifacts - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: rusty-v8-${{ needs.metadata.outputs.v8_version }}-${{ matrix.target }} path: dist/${{ matrix.target }}/* @@ -174,12 +174,12 @@ jobs: exit 1 fi - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: path: dist - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2 with: tag_name: ${{ needs.metadata.outputs.release_tag }} name: ${{ needs.metadata.outputs.release_tag }} diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index c5026fe8c5..45c983ac1e 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -13,7 +13,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Install Linux bwrap build dependencies shell: bash @@ -23,21 +23,82 @@ jobs: sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - name: Setup pnpm - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 with: run_install: false - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: 22 cache: pnpm - - uses: dtolnay/rust-toolchain@1.93.0 + - name: Set up Bazel CI + id: setup_bazel + uses: ./.github/actions/setup-bazel-ci + with: + target: x86_64-unknown-linux-gnu - - name: build codex - run: cargo build --bin codex - working-directory: codex-rs + - name: Build codex with Bazel + env: + BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} + shell: bash + run: | + set -euo pipefail + # Use the shared CI wrapper so fork PRs fall back cleanly when + # BuildBuddy credentials are unavailable. This workflow needs the + # built `codex` binary on disk afterwards, so ask the wrapper to + # override CI's default remote_download_minimal behavior. + ./.github/scripts/run-bazel-ci.sh \ + --remote-download-toplevel \ + -- \ + build \ + --build_metadata=COMMIT_SHA=${GITHUB_SHA} \ + --build_metadata=TAG_job=sdk \ + -- \ + //codex-rs/cli:codex + + # Resolve the exact output file using the same wrapper/config path as + # the build instead of guessing which Bazel convenience symlink is + # available on the runner. + cquery_output="$( + ./.github/scripts/run-bazel-ci.sh \ + -- \ + cquery \ + --output=files \ + -- \ + //codex-rs/cli:codex \ + | grep -E '^(/|bazel-out/)' \ + | tail -n 1 + )" + if [[ "${cquery_output}" = /* ]]; then + codex_bazel_output_path="${cquery_output}" + else + codex_bazel_output_path="${GITHUB_WORKSPACE}/${cquery_output}" + fi + if [[ -z "${codex_bazel_output_path}" ]]; then + echo "Bazel did not report an output path for //codex-rs/cli:codex." >&2 + exit 1 + fi + if [[ ! -e "${codex_bazel_output_path}" ]]; then + echo "Unable to locate the Bazel-built codex binary at ${codex_bazel_output_path}." >&2 + exit 1 + fi + + # Stage the binary into the workspace and point the SDK tests at that + # stable path. The tests spawn `codex` directly many times, so using a + # normal executable path is more reliable than invoking Bazel for each + # test process. + install_dir="${GITHUB_WORKSPACE}/.tmp/sdk-ci" + mkdir -p "${install_dir}" + install -m 755 "${codex_bazel_output_path}" "${install_dir}/codex" + echo "CODEX_EXEC_PATH=${install_dir}/codex" >> "$GITHUB_ENV" + + - name: Warm up Bazel-built codex + shell: bash + run: | + set -euo pipefail + "${CODEX_EXEC_PATH}" --version - name: Install dependencies run: pnpm install --frozen-lockfile @@ -50,3 +111,12 @@ jobs: - name: Test SDK packages run: pnpm -r --filter ./sdk/typescript run test + + - name: Save bazel repository cache + if: always() && !cancelled() && steps.setup_bazel.outputs.cache-hit != 'true' + continue-on-error: true + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + with: + path: | + ~/.cache/bazel-repo-cache + key: bazel-cache-x86_64-unknown-linux-gnu-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }} diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index 213c6a7b60..0dc7dc0054 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -38,10 +38,10 @@ jobs: v8_version: ${{ steps.v8_version.outputs.version }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: "3.12" @@ -72,13 +72,13 @@ jobs: target: aarch64-unknown-linux-musl steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Bazel - uses: bazelbuild/setup-bazelisk@v3 + uses: bazelbuild/setup-bazelisk@6ecf4fd8b7d1f9721785f1dd656a689acf9add47 # v3 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: "3.12" @@ -108,8 +108,8 @@ jobs: bazel \ --noexperimental_remote_repo_contents_cache \ - --bazelrc=.github/workflows/v8-ci.bazelrc \ "${bazel_args[@]}" \ + --config=ci-v8 \ "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - name: Stage release pair @@ -126,7 +126,7 @@ jobs: --output-dir "dist/${TARGET}" - name: Upload staged musl artifacts - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: v8-canary-${{ needs.metadata.outputs.v8_version }}-${{ matrix.target }} path: dist/${{ matrix.target }}/* diff --git a/.github/workflows/v8-ci.bazelrc b/.github/workflows/v8-ci.bazelrc deleted file mode 100644 index df1b4bec3d..0000000000 --- a/.github/workflows/v8-ci.bazelrc +++ /dev/null @@ -1,5 +0,0 @@ -import %workspace%/.github/workflows/ci.bazelrc - -common --build_metadata=REPO_URL=https://github.com/openai/codex.git -common --build_metadata=ROLE=CI -common --build_metadata=VISIBILITY=PUBLIC diff --git a/.gitignore b/.gitignore index 8f39b7b1c8..82269594bb 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ node_modules # build dist/ bazel-* +user.bazelrc build/ out/ storybook-static/ diff --git a/AGENTS.md b/AGENTS.md index 3a287a5991..297fb0fe15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,10 +15,13 @@ In the codex-rs folder where the rust code lives: - When you cannot make that API change and still need a small positional-literal callsite in Rust, follow the `argument_comment_lint` convention: - Use an exact `/*param_name*/` comment before opaque literal arguments such as `None`, booleans, and numeric literals when passing them by position. - Do not add these comments for string or char literals unless the comment adds real clarity; those literals are intentionally exempt from the lint. - - If you add one of these comments, the parameter name must exactly match the callee signature. + - The parameter name in the comment must exactly match the callee signature. + - You can run `just argument-comment-lint` to run the lint check locally. This is powered by Bazel, so running it the first time can be slow if Bazel is not warmed up, though incremental invocations should take <15s. Most of the time, it is best to update the PR and let CI take responsibility for checking this (or run it asynchronously in the background after submitting the PR). Note CI checks all three platforms, which the local run does not. - When possible, make `match` statements exhaustive and avoid wildcard arms. +- Newly added traits should include doc comments that explain their role and how implementations are expected to use them. - When writing tests, prefer comparing the equality of entire objects over fields one by one. - When making a change that adds or changes an API, ensure that the documentation in the `docs/` folder is up to date if applicable. +- Prefer private modules and explicitly exported public crate API. - If you change `ConfigToml` or nested config types, run `just write-config-schema` to update `codex-rs/core/config.schema.json`. - If you change Rust dependencies (`Cargo.toml` or `Cargo.lock`), run `just bazel-lock-update` from the repo root to refresh `MODULE.bazel.lock`, and include that lockfile update in the same change. @@ -40,6 +43,7 @@ In the codex-rs folder where the rust code lives: `codex-rs/tui/src/bottom_pane/mod.rs`, and similarly central orchestration modules. - When extracting code from a large module, move the related tests and module/type docs toward the new implementation so the invariants stay close to the code that owns them. +- When running Rust commands (e.g. `just fix` or `cargo test`) be patient with the command and never try to kill them using the PID. Rust lock can make the execution slow, this is expected. Run `just fmt` (in `codex-rs` directory) automatically after you have finished making Rust code changes; do not ask for approval to run it. Additionally, run the tests: @@ -48,7 +52,18 @@ Run `just fmt` (in `codex-rs` directory) automatically after you have finished m Before finalizing a large change to `codex-rs`, run `just fix -p ` (in `codex-rs` directory) to fix any linter issues in the code. Prefer scoping with `-p` to avoid slow workspace‑wide Clippy builds; only run `just fix` without `-p` if you changed shared crates. Do not re-run tests after running `fix` or `fmt`. -Also run `just argument-comment-lint` to ensure the codebase is clean of comment lint errors. +## The `codex-core` crate + +Over time, the `codex-core` crate (defined in `codex-rs/core/`) has become bloated because it is the largest crate, so it is often easier to add something new to `codex-core` rather than refactor out the library code you need so your new code neither takes a dependency on, nor contributes to the size of, `codex-core`. + +To that end: **resist adding code to codex-core**! + +Particularly when introducing a new concept/feature/API, before adding to `codex-core`, consider whether: + +- There is an existing crate other than `codex-core` that is an appropriate place for your new code to live. +- It is time to introduce a new crate to the Cargo workspace for your new functionality. Refactor existing code as necessary to make this happen. + +Likewise, when reviewing code, do not hesitate to push back on PRs that would unnecessarily add code to `codex-core`. ## TUI style conventions @@ -56,8 +71,6 @@ See `codex-rs/tui/styles.md`. ## TUI code conventions -- When a change lands in `codex-rs/tui` and `codex-rs/tui_app_server` has a parallel implementation of the same behavior, reflect the change in `codex-rs/tui_app_server` too unless there is a documented reason not to. - - Use concise styling helpers from ratatui’s Stylize trait. - Basic spans: use "text".into() - Styled spans: use "text".red(), "text".green(), "text".magenta(), "text".dim(), etc. diff --git a/BUILD.bazel b/BUILD.bazel index 0be4b711e6..3f59ff1160 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -17,12 +17,19 @@ platform( platform( name = "local_windows", constraint_values = [ - # We just need to pick one of the ABIs. Do the same one we target. "@rules_rs//rs/experimental/platforms/constraints:windows_gnullvm", ], parents = ["@platforms//host"], ) +platform( + name = "local_windows_msvc", + constraint_values = [ + "@rules_rs//rs/experimental/platforms/constraints:windows_msvc", + ], + parents = ["@platforms//host"], +) + alias( name = "rbe", actual = "@rbe_platform", diff --git a/MODULE.bazel b/MODULE.bazel index 8564db701a..bcc4ec076c 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -3,16 +3,35 @@ module(name = "codex") bazel_dep(name = "bazel_skylib", version = "1.8.2") bazel_dep(name = "platforms", version = "1.0.0") bazel_dep(name = "llvm", version = "0.6.8") +# The upstream LLVM archive contains a few unix-only symlink entries and is +# missing a couple of MinGW compatibility archives that windows-gnullvm needs +# during extraction and linking, so patch it until upstream grows native support. +single_version_override( + module_name = "llvm", + patch_strip = 1, + patches = [ + "//patches:llvm_windows_symlink_extract.patch", + ], +) +# Abseil picks a MinGW pthread TLS path that does not match our hermetic +# windows-gnullvm toolchain; force it onto the portable C++11 thread-local path. +single_version_override( + module_name = "abseil-cpp", + patch_strip = 1, + patches = [ + "//patches:abseil_windows_gnullvm_thread_identity.patch", + ], +) register_toolchains("@llvm//toolchain:all") osx = use_extension("@llvm//extensions:osx.bzl", "osx") osx.from_archive( - sha256 = "6a4922f89487a96d7054ec6ca5065bfddd9f1d017c74d82f1d79cecf7feb8228", - strip_prefix = "Payload/Library/Developer/CommandLineTools/SDKs/MacOSX26.2.sdk", + sha256 = "1bde70c0b1c2ab89ff454acbebf6741390d7b7eb149ca2a3ca24cc9203a408b7", + strip_prefix = "Payload/Library/Developer/CommandLineTools/SDKs/MacOSX26.4.sdk", type = "pkg", urls = [ - "https://swcdn.apple.com/content/downloads/26/44/047-81934-A_28TPKM5SD1/ps6pk6dk4x02vgfa5qsctq6tgf23t5f0w2/CLTools_macOSNMOS_SDK.pkg", + "https://swcdn.apple.com/content/downloads/32/53/047-96692-A_OAHIHT53YB/ybtshxmrcju8m2qvw3w5elr4rajtg1x3y3/CLTools_macOSNMOS_SDK.pkg", ], ) osx.frameworks(names = [ @@ -44,10 +63,81 @@ bazel_dep(name = "apple_support", version = "2.1.0") bazel_dep(name = "rules_cc", version = "0.2.16") bazel_dep(name = "rules_platform", version = "0.1.0") bazel_dep(name = "rules_rs", version = "0.0.43") +# `rules_rs` 0.0.43 does not model `windows-gnullvm` as a distinct Windows exec +# platform, so patch it until upstream grows that support for both x86_64 and +# aarch64. +single_version_override( + module_name = "rules_rs", + patch_strip = 1, + patches = [ + "//patches:rules_rs_windows_gnullvm_exec.patch", + "//patches:rules_rs_delete_git_worktree_pointer.patch", + "//patches:rules_rs_windows_exec_linker.patch", + ], + version = "0.0.43", +) rules_rust = use_extension("@rules_rs//rs/experimental:rules_rust.bzl", "rules_rust") +# Build-script probe binaries inherit CFLAGS/CXXFLAGS from Bazel's C++ +# toolchain. On `windows-gnullvm`, llvm-mingw does not ship +# `libssp_nonshared`, so strip the forwarded stack-protector flags there. +rules_rust.patch( + patches = [ + "//patches:rules_rust_windows_gnullvm_build_script.patch", + "//patches:rules_rust_windows_exec_msvc_build_script_env.patch", + "//patches:rules_rust_windows_bootstrap_process_wrapper_linker.patch", + "//patches:rules_rust_windows_build_script_runner_paths.patch", + "//patches:rules_rust_windows_msvc_direct_link_args.patch", + "//patches:rules_rust_windows_process_wrapper_skip_temp_outputs.patch", + "//patches:rules_rust_windows_exec_bin_target.patch", + "//patches:rules_rust_windows_exec_std.patch", + "//patches:rules_rust_windows_exec_rustc_dev_rlib.patch", + "//patches:rules_rust_repository_set_exec_constraints.patch", + ], + strip = 1, +) use_repo(rules_rust, "rules_rust") +nightly_rust = use_extension( + "@rules_rs//rs/experimental:rules_rust_reexported_extensions.bzl", + "rust", +) +nightly_rust.toolchain( + versions = ["nightly/2025-09-18"], + dev_components = True, + edition = "2024", +) +# Keep Windows exec tools on MSVC so Bazel helper binaries link correctly, but +# lint crate targets as `windows-gnullvm` to preserve the repo's actual cfgs. +nightly_rust.repository_set( + name = "rust_windows_x86_64", + dev_components = True, + edition = "2024", + exec_triple = "x86_64-pc-windows-msvc", + exec_compatible_with = [ + "@platforms//cpu:x86_64", + "@platforms//os:windows", + "@rules_rs//rs/experimental/platforms/constraints:windows_msvc", + ], + target_compatible_with = [ + "@platforms//cpu:x86_64", + "@platforms//os:windows", + "@rules_rs//rs/experimental/platforms/constraints:windows_msvc", + ], + target_triple = "x86_64-pc-windows-msvc", + versions = ["nightly/2025-09-18"], +) +nightly_rust.repository_set( + name = "rust_windows_x86_64", + target_compatible_with = [ + "@platforms//cpu:x86_64", + "@platforms//os:windows", + "@rules_rs//rs/experimental/platforms/constraints:windows_gnullvm", + ], + target_triple = "x86_64-pc-windows-gnullvm", +) +use_repo(nightly_rust, "rust_toolchains") + toolchains = use_extension("@rules_rs//rs/experimental/toolchains:module_extension.bzl", "toolchains") toolchains.toolchain( edition = "2024", @@ -56,6 +146,7 @@ toolchains.toolchain( use_repo(toolchains, "default_rust_toolchains") register_toolchains("@default_rust_toolchains//:all") +register_toolchains("@rust_toolchains//:all") crate = use_extension("@rules_rs//rs:extensions.bzl", "crate") crate.from_cargo( @@ -65,10 +156,33 @@ crate.from_cargo( "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "aarch64-apple-darwin", + # Keep both Windows ABIs in the generated Cargo metadata: the V8 + # experiment still consumes release assets that only exist under the + # MSVC names while targeting the GNU toolchain. + "aarch64-pc-windows-msvc", "aarch64-pc-windows-gnullvm", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-apple-darwin", + "x86_64-pc-windows-msvc", + "x86_64-pc-windows-gnullvm", + ], + use_experimental_platforms = True, +) +crate.from_cargo( + name = "argument_comment_lint_crates", + cargo_lock = "//tools/argument-comment-lint:Cargo.lock", + cargo_toml = "//tools/argument-comment-lint:Cargo.toml", + platform_triples = [ + "aarch64-unknown-linux-gnu", + "aarch64-unknown-linux-musl", + "aarch64-apple-darwin", + "aarch64-pc-windows-msvc", + "aarch64-pc-windows-gnullvm", + "x86_64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", + "x86_64-apple-darwin", + "x86_64-pc-windows-msvc", "x86_64-pc-windows-gnullvm", ], use_experimental_platforms = True, @@ -78,8 +192,18 @@ bazel_dep(name = "zstd", version = "1.5.7") crate.annotation( crate = "zstd-sys", - gen_build_script = "off", - deps = ["@zstd"], + gen_build_script = "on", + patch_args = ["-p1"], + patches = [ + "//patches:zstd-sys_windows_msvc_include_dirs.patch", + ], +) +crate.annotation( + crate = "ring", + patch_args = ["-p1"], + patches = [ + "//patches:ring_windows_msvc_include_dirs.patch", + ], ) crate.annotation( build_script_env = { @@ -89,12 +213,28 @@ crate.annotation( patch_args = ["-p1"], patches = [ "//patches:aws-lc-sys_memcmp_check.patch", + "//patches:aws-lc-sys_windows_msvc_prebuilt_nasm.patch", + "//patches:aws-lc-sys_windows_msvc_memcmp_probe.patch", ], ) +crate.annotation( + # The build script only validates embedded source/version metadata. + crate = "rustc_apfloat", + gen_build_script = "off", +) + inject_repo(crate, "zstd") +use_repo(crate, "argument_comment_lint_crates") bazel_dep(name = "bzip2", version = "1.0.8.bcr.3") +single_version_override( + module_name = "bzip2", + patch_strip = 1, + patches = [ + "//patches:bzip2_windows_stack_args.patch", + ], +) crate.annotation( crate = "bzip2-sys", @@ -108,20 +248,30 @@ bazel_dep(name = "zlib", version = "1.3.1.bcr.8") crate.annotation( crate = "libz-sys", - gen_build_script = "off", - deps = ["@zlib"], + gen_build_script = "on", ) inject_repo(crate, "zlib") -# TODO(zbarsky): Enable annotation after fixing windows arm64 builds. +bazel_dep(name = "xz", version = "5.4.5.bcr.8") +single_version_override( + module_name = "xz", + patch_strip = 1, + patches = [ + "//patches:xz_windows_stack_args.patch", + ], +) + crate.annotation( crate = "lzma-sys", - gen_build_script = "on", + gen_build_script = "off", + deps = ["@xz//:lzma"], ) bazel_dep(name = "openssl", version = "3.5.4.bcr.0") +inject_repo(crate, "xz") + crate.annotation( build_script_data = [ "@openssl//:gen_dir", diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index ee89f6243f..746679af39 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -228,6 +228,8 @@ "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", "https://bcr.bazel.build/modules/with_cfg.bzl/0.12.0/MODULE.bazel": "b573395fe63aef4299ba095173e2f62ccfee5ad9bbf7acaa95dba73af9fc2b38", "https://bcr.bazel.build/modules/with_cfg.bzl/0.12.0/source.json": "3f3fbaeafecaf629877ad152a2c9def21f8d330d91aa94c5dc75bbb98c10b8b8", + "https://bcr.bazel.build/modules/xz/5.4.5.bcr.8/MODULE.bazel": "e48a69bd54053c2ec5fffc2a29fb70122afd3e83ab6c07068f63bc6553fa57cc", + "https://bcr.bazel.build/modules/xz/5.4.5.bcr.8/source.json": "bd7e928ccd63505b44f4784f7bbf12cc11f9ff23bf3ca12ff2c91cd74846099e", "https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0", "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/MODULE.bazel": "eec517b5bbe5492629466e11dae908d043364302283de25581e3eb944326c4ca", "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.8/MODULE.bazel": "772c674bb78a0342b8caf32ab5c25085c493ca4ff08398208dcbe4375fe9f776", @@ -611,17 +613,15 @@ "anstyle-query_1.1.5": "{\"dependencies\":[{\"features\":[\"Win32_System_Console\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\">=0.60.2, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "anstyle-wincon_3.0.11": "{\"dependencies\":[{\"name\":\"anstyle\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"lexopt\",\"req\":\"^0.3.1\"},{\"name\":\"once_cell_polyfill\",\"req\":\"^1.56.1\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_System_Console\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\">=0.60.2, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "anstyle_1.0.13": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"lexopt\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.5\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", + "anstyle_1.0.14": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"lexopt\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.23\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "anyhow_1.0.101": "{\"dependencies\":[{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.51\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.6\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", + "anyhow_1.0.102": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.6\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"}],\"features\":{\"backtrace\":[],\"default\":[\"std\"],\"std\":[]}}", "arbitrary_1.4.2": "{\"dependencies\":[{\"name\":\"derive_arbitrary\",\"optional\":true,\"req\":\"~1.4.0\"},{\"kind\":\"dev\",\"name\":\"exhaustigen\",\"req\":\"^0.1.0\"}],\"features\":{\"derive\":[\"derive_arbitrary\"]}}", "arboard_3.6.1": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"clipboard-win\",\"req\":\"^5.3.1\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.2\"},{\"default_features\":false,\"features\":[\"png\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"default_features\":false,\"features\":[\"tiff\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"png\",\"bmp\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(windows)\"},{\"name\":\"log\",\"req\":\"^0.4\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"log\",\"req\":\"^0.4\",\"target\":\"cfg(windows)\"},{\"name\":\"objc2\",\"req\":\"^0.6.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"objc2-core-graphics\",\"NSPasteboard\",\"NSPasteboardItem\",\"NSImage\"],\"name\":\"objc2-app-kit\",\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"CFCGTypes\"],\"name\":\"objc2-core-foundation\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"CGImage\",\"CGColorSpace\",\"CGDataProvider\"],\"name\":\"objc2-core-graphics\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"NSArray\",\"NSString\",\"NSEnumerator\",\"NSGeometry\",\"NSValue\"],\"name\":\"objc2-foundation\",\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.1\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"features\":[\"Win32_Foundation\",\"Win32_Storage_FileSystem\",\"Win32_System_DataExchange\",\"Win32_System_Memory\",\"Win32_System_Ole\",\"Win32_UI_Shell\"],\"name\":\"windows-sys\",\"req\":\">=0.52.0, <0.61.0\",\"target\":\"cfg(windows)\"},{\"name\":\"wl-clipboard-rs\",\"optional\":true,\"req\":\"^0.9.0\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"x11rb\",\"req\":\"^0.13\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"}],\"features\":{\"core-graphics\":[\"dep:objc2-core-graphics\"],\"default\":[\"image-data\"],\"image\":[\"dep:image\"],\"image-data\":[\"dep:objc2-core-graphics\",\"dep:objc2-core-foundation\",\"image\",\"windows-sys\",\"core-graphics\"],\"wayland-data-control\":[\"wl-clipboard-rs\"],\"windows-sys\":[\"windows-sys/Win32_Graphics_Gdi\"],\"wl-clipboard-rs\":[\"dep:wl-clipboard-rs\"]}}", - "arc-swap_1.8.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"adaptive-barrier\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"~0.7\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"~0.8\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"~0.12\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"rustversion\",\"req\":\"^1\"},{\"features\":[\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.130\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.177\"}],\"features\":{\"experimental-strategies\":[],\"experimental-thread-local\":[],\"internal-test-strategies\":[],\"weak\":[]}}", + "arc-swap_1.9.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"adaptive-barrier\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"~0.7\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"~0.8\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"~0.12\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"rustversion\",\"req\":\"^1\"},{\"features\":[\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.130\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.177\"}],\"features\":{\"experimental-strategies\":[],\"experimental-thread-local\":[],\"internal-test-strategies\":[],\"weak\":[]}}", "arrayvec_0.7.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.4\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"matches\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.4\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "ascii-canvas_3.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"diff\",\"req\":\"^0.1\"},{\"name\":\"term\",\"req\":\"^0.7\"}],\"features\":{}}", "ascii_1.1.0": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"name\":\"serde_test\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", - "askama_0.15.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"askama_macros\",\"optional\":true,\"req\":\"=0.15.4\"},{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\"},{\"name\":\"itoa\",\"req\":\"^1.0.11\"},{\"default_features\":false,\"name\":\"percent-encoding\",\"optional\":true,\"req\":\"^2.1.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"alloc\":[\"askama_macros?/alloc\",\"serde?/alloc\",\"serde_json?/alloc\",\"percent-encoding?/alloc\"],\"code-in-doc\":[\"askama_macros?/code-in-doc\"],\"config\":[\"askama_macros?/config\"],\"default\":[\"config\",\"derive\",\"std\",\"urlencode\"],\"derive\":[\"dep:askama_macros\",\"dep:askama_macros\"],\"full\":[\"default\",\"code-in-doc\",\"serde_json\"],\"nightly-spans\":[\"askama_macros/nightly-spans\"],\"serde_json\":[\"std\",\"askama_macros?/serde_json\",\"dep:serde\",\"dep:serde_json\"],\"std\":[\"alloc\",\"askama_macros?/std\",\"serde?/std\",\"serde_json?/std\",\"percent-encoding?/std\"],\"urlencode\":[\"askama_macros?/urlencode\",\"dep:percent-encoding\"]}}", - "askama_derive_0.15.4": "{\"dependencies\":[{\"name\":\"basic-toml\",\"optional\":true,\"req\":\"^0.1.1\"},{\"kind\":\"dev\",\"name\":\"console\",\"req\":\"^0.16.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\"},{\"name\":\"memchr\",\"req\":\"^2\"},{\"name\":\"parser\",\"package\":\"askama_parser\",\"req\":\"=0.15.4\"},{\"kind\":\"dev\",\"name\":\"prettyplease\",\"req\":\"^0.2.20\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"pulldown-cmark\",\"optional\":true,\"req\":\"^0.13.0\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"rustc-hash\",\"req\":\"^2.0.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"similar\",\"req\":\"^2.6.0\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"derive\",\"full\",\"parsing\",\"printing\"],\"name\":\"syn\",\"req\":\"^2.0.3\"}],\"features\":{\"alloc\":[],\"code-in-doc\":[\"dep:pulldown-cmark\"],\"config\":[\"external-sources\",\"dep:basic-toml\",\"dep:serde\",\"dep:serde_derive\",\"parser/config\"],\"default\":[\"alloc\",\"code-in-doc\",\"config\",\"external-sources\",\"proc-macro\",\"serde_json\",\"std\",\"urlencode\"],\"external-sources\":[],\"nightly-spans\":[],\"proc-macro\":[\"proc-macro2/proc-macro\"],\"serde_json\":[],\"std\":[\"alloc\"],\"urlencode\":[]}}", - "askama_macros_0.15.4": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"external-sources\",\"proc-macro\"],\"name\":\"askama_derive\",\"package\":\"askama_derive\",\"req\":\"=0.15.4\"}],\"features\":{\"alloc\":[\"askama_derive/alloc\"],\"code-in-doc\":[\"askama_derive/code-in-doc\"],\"config\":[\"askama_derive/config\"],\"default\":[\"config\",\"derive\",\"std\",\"urlencode\"],\"derive\":[],\"full\":[\"default\",\"code-in-doc\",\"serde_json\"],\"nightly-spans\":[\"askama_derive/nightly-spans\"],\"serde_json\":[\"askama_derive/serde_json\"],\"std\":[\"askama_derive/std\"],\"urlencode\":[\"askama_derive/urlencode\"]}}", - "askama_parser_0.15.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\"},{\"name\":\"rustc-hash\",\"req\":\"^2.0.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"unicode-ident\",\"req\":\"^1.0.12\"},{\"features\":[\"simd\"],\"name\":\"winnow\",\"req\":\"^0.7.0\"}],\"features\":{\"config\":[\"dep:serde\",\"dep:serde_derive\"]}}", "asn1-rs-derive_0.6.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"name\":\"synstructure\",\"req\":\"^0.13\"}],\"features\":{}}", "asn1-rs-impl_0.2.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}", "asn1-rs_0.7.1": "{\"dependencies\":[{\"name\":\"asn1-rs-derive\",\"req\":\"^0.6\"},{\"name\":\"asn1-rs-impl\",\"req\":\"^0.2\"},{\"name\":\"bitvec\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"colored\",\"optional\":true,\"req\":\"^3.0\"},{\"kind\":\"dev\",\"name\":\"colored\",\"req\":\"^3.0\"},{\"name\":\"cookie-factory\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"displaydoc\",\"req\":\"^0.2.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"nom\",\"req\":\"^7.0\"},{\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"num-traits\",\"req\":\"^0.2.14\"},{\"kind\":\"dev\",\"name\":\"pem\",\"req\":\"^3.0\"},{\"name\":\"rusticata-macros\",\"req\":\"^4.0\"},{\"name\":\"thiserror\",\"req\":\"^2.0.0\"},{\"features\":[\"macros\",\"parsing\",\"formatting\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"}],\"features\":{\"bigint\":[\"num-bigint\"],\"bits\":[\"bitvec\"],\"datetime\":[\"time\"],\"debug\":[\"std\",\"colored\"],\"default\":[\"std\"],\"serialize\":[\"cookie-factory\"],\"std\":[],\"trace\":[\"debug\"]}}", @@ -662,6 +662,7 @@ "bit-vec_0.6.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"rand_xorshift\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"serde_no_std\":[\"serde/alloc\"],\"serde_std\":[\"std\",\"serde/std\"],\"std\":[]}}", "bitflags_1.3.2": "{\"dependencies\":[{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.2\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3\"}],\"features\":{\"default\":[],\"example_generated\":[],\"rustc-dep-of-std\":[\"core\",\"compiler_builtins\"]}}", "bitflags_2.10.0": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"arbitrary\",\"req\":\"^1.0\"},{\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1.12\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.12.2\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.228\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde_lib\",\"package\":\"serde\",\"req\":\"^1.0.103\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.19\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.18\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"zerocopy\",\"req\":\"^0.8\"}],\"features\":{\"example_generated\":[],\"serde\":[\"serde_core\"],\"std\":[]}}", + "bitflags_2.11.0": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"arbitrary\",\"req\":\"^1.0\"},{\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1.12\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.12.2\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.228\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde_lib\",\"package\":\"serde\",\"req\":\"^1.0.103\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.19\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.18\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"zerocopy\",\"req\":\"^0.8\"}],\"features\":{\"example_generated\":[],\"serde\":[\"serde_core\"],\"std\":[]}}", "block-buffer_0.10.4": "{\"dependencies\":[{\"name\":\"generic-array\",\"req\":\"^0.14\"}],\"features\":{}}", "block-padding_0.3.3": "{\"dependencies\":[{\"name\":\"generic-array\",\"req\":\"^0.14\"}],\"features\":{\"std\":[]}}", "block2_0.6.2": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"objc2\",\"req\":\">=0.6.2, <0.8.0\"}],\"features\":{\"alloc\":[],\"compiler-rt\":[\"objc2/unstable-compiler-rt\"],\"default\":[\"std\"],\"gnustep-1-7\":[\"objc2/gnustep-1-7\"],\"gnustep-1-8\":[\"gnustep-1-7\",\"objc2/gnustep-1-8\"],\"gnustep-1-9\":[\"gnustep-1-8\",\"objc2/gnustep-1-9\"],\"gnustep-2-0\":[\"gnustep-1-9\",\"objc2/gnustep-2-0\"],\"gnustep-2-1\":[\"gnustep-2-0\",\"objc2/gnustep-2-1\"],\"std\":[\"alloc\"],\"unstable-coerce-pointee\":[],\"unstable-objfw\":[],\"unstable-private\":[],\"unstable-winobjc\":[\"gnustep-1-8\"]}}", @@ -681,10 +682,14 @@ "cached_proc_macro_0.25.0": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.20.8\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.49\"},{\"name\":\"quote\",\"req\":\"^1.0.6\"},{\"name\":\"syn\",\"req\":\"^2.0.52\"}],\"features\":{}}", "cached_proc_macro_types_0.1.1": "{\"dependencies\":[],\"features\":{}}", "calendrical_calculations_0.2.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"core_maths\",\"req\":\"^0.1.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"}],\"features\":{\"logging\":[\"dep:log\"]}}", + "camino_1.2.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"name\":\"proptest\",\"optional\":true,\"req\":\"^1.0.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.223\"},{\"kind\":\"dev\",\"name\":\"serde_bytes\",\"req\":\"^0.11.8\"},{\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"proptest1\":[\"dep:proptest\"],\"serde1\":[\"dep:serde_core\"]}}", + "cargo-platform_0.3.2": "{\"dependencies\":[{\"name\":\"serde\",\"req\":\"^1.0.228\",\"target\":\"cfg(any())\"},{\"name\":\"serde_core\",\"req\":\"^1.0.228\"}],\"features\":{}}", + "cargo_metadata_0.23.1": "{\"dependencies\":[{\"features\":[\"serde1\"],\"name\":\"camino\",\"req\":\"^1.1.10\"},{\"name\":\"cargo-platform\",\"req\":\"^0.3.0\"},{\"name\":\"derive_builder\",\"optional\":true,\"req\":\"^0.20\"},{\"features\":[\"serde\"],\"name\":\"semver\",\"req\":\"^1.0.26\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.219\"},{\"features\":[\"unbounded_depth\"],\"name\":\"serde_json\",\"req\":\"^1.0.142\"},{\"name\":\"thiserror\",\"req\":\"^2.0.12\"}],\"features\":{\"builder\":[\"derive_builder\"],\"default\":[],\"unstable\":[]}}", "cassowary_0.3.0": "{\"dependencies\":[],\"features\":{}}", "castaway_0.2.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1\"},{\"name\":\"rustversion\",\"req\":\"^1\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", "cbc_0.1.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"aes\",\"req\":\"^0.8\"},{\"name\":\"cipher\",\"req\":\"^0.4.2\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"cipher\",\"req\":\"^0.4.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3.3\"}],\"features\":{\"alloc\":[\"cipher/alloc\"],\"block-padding\":[\"cipher/block-padding\"],\"default\":[\"block-padding\"],\"std\":[\"cipher/std\",\"alloc\"],\"zeroize\":[\"cipher/zeroize\"]}}", "cc_1.2.55": "{\"dependencies\":[{\"name\":\"find-msvc-tools\",\"req\":\"^0.1.9\"},{\"default_features\":false,\"name\":\"jobserver\",\"optional\":true,\"req\":\"^0.1.30\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.62\",\"target\":\"cfg(unix)\"},{\"name\":\"shlex\",\"req\":\"^1.3.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"jobserver\":[],\"parallel\":[\"dep:libc\",\"dep:jobserver\"]}}", + "cc_1.2.56": "{\"dependencies\":[{\"name\":\"find-msvc-tools\",\"req\":\"^0.1.9\"},{\"default_features\":false,\"name\":\"jobserver\",\"optional\":true,\"req\":\"^0.1.30\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.62\",\"target\":\"cfg(unix)\"},{\"name\":\"shlex\",\"req\":\"^1.3.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"jobserver\":[],\"parallel\":[\"dep:libc\",\"dep:jobserver\"]}}", "cesu8_1.1.0": "{\"dependencies\":[],\"features\":{\"unstable\":[]}}", "cexpr_0.6.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"clang-sys\",\"req\":\">=0.13.0, <0.29.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"nom\",\"req\":\"^7\"}],\"features\":{}}", "cfg-if_1.0.4": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"}],\"features\":{\"rustc-dep-of-std\":[\"core\"]}}", @@ -709,8 +714,10 @@ "color-spantrace_0.3.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ansi-parser\",\"req\":\"^0.8\"},{\"name\":\"once_cell\",\"req\":\"^1.18.0\"},{\"name\":\"owo-colors\",\"req\":\"^4.0\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.29\"},{\"name\":\"tracing-core\",\"req\":\"^0.1.21\"},{\"name\":\"tracing-error\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.4\"}],\"features\":{}}", "color_quant_1.1.0": "{\"dependencies\":[],\"features\":{}}", "colorchoice_1.0.4": "{\"dependencies\":[],\"features\":{}}", + "colorchoice_1.0.5": "{\"dependencies\":[],\"features\":{}}", "combine_4.6.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"bytes_05\",\"optional\":true,\"package\":\"bytes\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"bytes_05\",\"package\":\"bytes\",\"req\":\"^0.5\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"futures-03-dep\",\"package\":\"futures\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"futures-core-03\",\"optional\":true,\"package\":\"futures-core\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"futures-io-03\",\"optional\":true,\"package\":\"futures-io\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.3\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.0\"},{\"features\":[\"tokio\",\"quickcheck\"],\"kind\":\"dev\",\"name\":\"partial-io\",\"req\":\"^0.3\"},{\"name\":\"pin-project-lite\",\"optional\":true,\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"quick-error\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.6\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio-02-dep\",\"optional\":true,\"package\":\"tokio\",\"req\":\"^0.2.3\"},{\"features\":[\"fs\",\"io-driver\",\"io-util\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio-02-dep\",\"package\":\"tokio\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"tokio-03-dep\",\"optional\":true,\"package\":\"tokio\",\"req\":\"^0.3\"},{\"features\":[\"fs\",\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio-03-dep\",\"package\":\"tokio\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"tokio-dep\",\"optional\":true,\"package\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"fs\",\"macros\",\"rt\",\"rt-multi-thread\",\"io-util\"],\"kind\":\"dev\",\"name\":\"tokio-dep\",\"package\":\"tokio\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"codec\"],\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"futures-03\":[\"pin-project\",\"std\",\"futures-core-03\",\"futures-io-03\",\"pin-project-lite\"],\"mp4\":[],\"pin-project\":[\"pin-project-lite\"],\"std\":[\"memchr/std\",\"bytes\",\"alloc\"],\"tokio\":[\"tokio-dep\",\"tokio-util/io\",\"futures-core-03\",\"pin-project-lite\"],\"tokio-02\":[\"pin-project\",\"std\",\"tokio-02-dep\",\"futures-core-03\",\"pin-project-lite\",\"bytes_05\"],\"tokio-03\":[\"pin-project\",\"std\",\"tokio-03-dep\",\"futures-core-03\",\"pin-project-lite\"]}}", "compact_str_0.8.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"borsh\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"castaway\",\"req\":\"^0.2.3\"},{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"cfg-if\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"diesel\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"itoa\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"markup\",\"optional\":true,\"req\":\"^0.13\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"proptest\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"size_32\"],\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"alloc\",\"size_32\"],\"kind\":\"dev\",\"name\":\"rkyv\",\"req\":\"^0.7\"},{\"name\":\"rustversion\",\"req\":\"^1\"},{\"name\":\"ryu\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"features\":[\"union\"],\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"sqlx\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"static_assertions\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"test-case\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"test-strategy\",\"req\":\"^0.3\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"borsh\":[\"dep:borsh\"],\"bytes\":[\"dep:bytes\"],\"default\":[\"std\"],\"diesel\":[\"dep:diesel\"],\"markup\":[\"dep:markup\"],\"proptest\":[\"dep:proptest\"],\"quickcheck\":[\"dep:quickcheck\"],\"rkyv\":[\"dep:rkyv\"],\"serde\":[\"dep:serde\"],\"smallvec\":[\"dep:smallvec\"],\"sqlx\":[\"dep:sqlx\",\"std\"],\"sqlx-mysql\":[\"sqlx\",\"sqlx/mysql\"],\"sqlx-postgres\":[\"sqlx\",\"sqlx/postgres\"],\"sqlx-sqlite\":[\"sqlx\",\"sqlx/sqlite\"],\"std\":[]}}", + "compiletest_rs_0.11.2": "{\"dependencies\":[{\"name\":\"diff\",\"req\":\"^0.1.10\"},{\"name\":\"filetime\",\"req\":\"^0.2\"},{\"name\":\"getopts\",\"req\":\"^0.2\"},{\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"miow\",\"req\":\"^0.6\",\"target\":\"cfg(windows)\"},{\"name\":\"regex\",\"req\":\"^1.0\"},{\"name\":\"rustfix\",\"req\":\"^0.8\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"tempfile\",\"optional\":true,\"req\":\"^3.0\"},{\"name\":\"tester\",\"req\":\"^0.9\"},{\"features\":[\"Win32\"],\"name\":\"windows-sys\",\"req\":\"^0.59\",\"target\":\"cfg(windows)\"}],\"features\":{\"rustc\":[],\"stable\":[],\"tmp\":[\"tempfile\"]}}", "concurrent-queue_2.5.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.11\"},{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3.1.0\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "console_0.15.11": "{\"dependencies\":[{\"name\":\"encode_unicode\",\"req\":\"^1\",\"target\":\"cfg(windows)\"},{\"name\":\"libc\",\"req\":\"^0.2.99\"},{\"name\":\"once_cell\",\"req\":\"^1.8\"},{\"default_features\":false,\"features\":[\"std\",\"bit-set\",\"break-dead-code\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.4.2\"},{\"name\":\"unicode-width\",\"optional\":true,\"req\":\"^0.2\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Console\",\"Win32_Storage_FileSystem\",\"Win32_UI_Input_KeyboardAndMouse\"],\"name\":\"windows-sys\",\"req\":\"^0.59\",\"target\":\"cfg(windows)\"}],\"features\":{\"ansi-parsing\":[],\"default\":[\"unicode-width\",\"ansi-parsing\"],\"windows-console-colors\":[\"ansi-parsing\"]}}", "const-hex_1.17.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"},{\"kind\":\"dev\",\"name\":\"divan\",\"package\":\"codspeed-divan-compat\",\"req\":\"^3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"faster-hex\",\"req\":\"^0.10.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"~0.4.2\"},{\"default_features\":false,\"name\":\"proptest\",\"optional\":true,\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"rustc-hex\",\"req\":\"^2.1\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"__fuzzing\":[\"dep:proptest\",\"std\"],\"alloc\":[\"serde_core?/alloc\",\"proptest?/alloc\"],\"core-error\":[],\"default\":[\"std\"],\"force-generic\":[],\"hex\":[],\"nightly\":[],\"portable-simd\":[],\"serde\":[\"dep:serde_core\"],\"std\":[\"serde_core?/std\",\"proptest?/std\",\"alloc\"]}}", @@ -797,6 +804,10 @@ "dunce_1.0.5": "{\"dependencies\":[],\"features\":{}}", "dupe_0.9.1": "{\"dependencies\":[{\"name\":\"dupe_derive\",\"req\":\"=0.9.1\"}],\"features\":{}}", "dupe_derive_0.9.1": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0.3\"},{\"features\":[\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", + "dylint_5.0.0": "{\"dependencies\":[{\"name\":\"anstyle\",\"req\":\"^1.0\"},{\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"cargo-util\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"cargo-util-schemas\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"cargo_metadata\",\"req\":\"^0.23\"},{\"default_features\":false,\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"dunce\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"config\",\"git\",\"packaging\",\"rustup\"],\"name\":\"dylint_internal\",\"req\":\"=5.0.0\"},{\"features\":[\"cargo\"],\"kind\":\"build\",\"name\":\"dylint_internal\",\"req\":\"=5.0.0\"},{\"features\":[\"examples\"],\"kind\":\"dev\",\"name\":\"dylint_internal\",\"req\":\"=5.0.0\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"fs_extra\",\"optional\":true,\"req\":\"^1.3\"},{\"name\":\"glob\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"heck\",\"optional\":true,\"req\":\"^0.5\"},{\"name\":\"hex\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"if_chain\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"once_cell\",\"req\":\"^1.21\"},{\"name\":\"rewriter\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"rustc-stable-hash\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"semver\",\"req\":\"^1.0\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde-untagged\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"default-syntaxes\",\"regex-fancy\",\"parsing\"],\"name\":\"syntect\",\"optional\":true,\"req\":\"^5.3\"},{\"name\":\"tempfile\",\"req\":\"^3.23\"},{\"name\":\"toml\",\"optional\":true,\"req\":\"^0.9\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.5\"},{\"name\":\"walkdir\",\"optional\":true,\"req\":\"^2.5\"}],\"features\":{\"__cargo_cli\":[\"cargo-util\",\"cargo-util-schemas\",\"dunce\",\"dylint_internal/home\",\"fs_extra\",\"glob\",\"hex\",\"if_chain\",\"rustc-stable-hash\",\"serde-untagged\",\"toml\",\"url\"],\"default\":[],\"library_packages\":[\"__cargo_cli\"],\"package_options\":[\"chrono\",\"dylint_internal/clippy_utils\",\"dylint_internal/git\",\"heck\",\"if_chain\",\"rewriter\",\"syntect\",\"walkdir\"]}}", + "dylint_internal_5.0.0": "{\"dependencies\":[{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"assert_cmd\",\"req\":\"^2.0\"},{\"name\":\"bitflags\",\"optional\":true,\"req\":\"^2.9\"},{\"name\":\"cargo-util\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"cargo_metadata\",\"optional\":true,\"req\":\"^0.23\"},{\"name\":\"ctor\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"env_logger\",\"optional\":true,\"req\":\"^0.11\"},{\"name\":\"git2\",\"optional\":true,\"req\":\"^0.20\"},{\"name\":\"home\",\"optional\":true,\"req\":\"=0.5.9\"},{\"name\":\"if_chain\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"predicates\",\"req\":\"^3.1\"},{\"name\":\"regex\",\"req\":\"^1.11\"},{\"name\":\"rustversion\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"semver\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"tar\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"tempfile\",\"optional\":true,\"req\":\"^3.23\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.23\"},{\"name\":\"thiserror\",\"optional\":true,\"req\":\"^2.0\"},{\"name\":\"toml\",\"optional\":true,\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.9\"},{\"name\":\"toml_edit\",\"optional\":true,\"req\":\"^0.23\"},{\"kind\":\"dev\",\"name\":\"toml_edit\",\"req\":\"^0.23\"},{\"name\":\"walkdir\",\"optional\":true,\"req\":\"^2.5\"}],\"features\":{\"cargo\":[\"anstyle\",\"bitflags\",\"cargo_metadata\",\"home\"],\"clippy_utils\":[\"git\",\"semver\",\"tempfile\",\"toml\",\"toml_edit\"],\"config\":[\"cargo_metadata\",\"serde\",\"thiserror\",\"toml\"],\"examples\":[\"cargo\",\"cargo-util\",\"rustup\",\"walkdir\"],\"git\":[\"git2\",\"if_chain\"],\"home\":[\"dep:home\",\"rustversion\"],\"match_def_path\":[],\"packaging\":[\"cargo\",\"tar\"],\"rustup\":[\"cargo_metadata\"],\"testing\":[\"ctor\",\"env_logger\",\"packaging\"]}}", + "dylint_linting_5.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_cmd\",\"req\":\"^2.0\"},{\"name\":\"cargo_metadata\",\"req\":\"^0.23\"},{\"features\":[\"config\"],\"name\":\"dylint_internal\",\"req\":\"=5.0.0\"},{\"name\":\"paste\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustc_version\",\"req\":\"^0.4\"},{\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.23\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"name\":\"toml\",\"req\":\"^0.9\"},{\"kind\":\"build\",\"name\":\"toml\",\"req\":\"^0.9\"}],\"features\":{\"constituent\":[]}}", + "dylint_testing_5.0.0": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"cargo_metadata\",\"req\":\"^0.23\"},{\"name\":\"compiletest_rs\",\"req\":\"^0.11\"},{\"name\":\"dylint\",\"req\":\"=5.0.0\"},{\"name\":\"dylint_internal\",\"req\":\"=5.0.0\"},{\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"once_cell\",\"req\":\"^1.21\"},{\"name\":\"regex\",\"req\":\"^1.11\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"tempfile\",\"req\":\"^3.23\"}],\"features\":{\"default\":[],\"deny_warnings\":[]}}", "dyn-clone_1.0.20": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.66\"}],\"features\":{}}", "either_1.15.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\",\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.95\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[],\"use_std\":[\"std\"]}}", "ena_0.14.3": "{\"dependencies\":[{\"name\":\"dogged\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"log\",\"req\":\"^0.4\"}],\"features\":{\"bench\":[],\"persistent\":[\"dogged\"]}}", @@ -870,6 +881,7 @@ "getopts_0.2.24": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"std\",\"optional\":true,\"package\":\"rustc-std-workspace-std\",\"req\":\"^1.0\"},{\"name\":\"unicode-width\",\"optional\":true,\"req\":\"^0.2.0\"}],\"features\":{\"default\":[\"unicode\"],\"rustc-dep-of-std\":[\"std\",\"core\"],\"unicode\":[\"dep:unicode-width\"]}}", "getrandom_0.2.17": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(unix)\"},{\"default_features\":false,\"name\":\"wasi\",\"req\":\"^0.11\",\"target\":\"cfg(target_os = \\\"wasi\\\")\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.62\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.18\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"}],\"features\":{\"custom\":[],\"js\":[\"wasm-bindgen\",\"js-sys\"],\"linux_disable_fallback\":[],\"rdrand\":[],\"rustc-dep-of-std\":[\"compiler_builtins\",\"core\",\"libc/rustc-dep-of-std\",\"wasi/rustc-dep-of-std\"],\"std\":[],\"test-in-browser\":[]}}", "getrandom_0.3.4": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.77\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"), target_feature = \\\"atomics\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), not(any(all(target_os = \\\"linux\\\", target_env = \\\"\\\"), getrandom_backend = \\\"custom\\\", getrandom_backend = \\\"linux_raw\\\", getrandom_backend = \\\"rdrand\\\", getrandom_backend = \\\"rndr\\\"))))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"hurd\\\", target_os = \\\"illumos\\\", target_os = \\\"cygwin\\\", all(target_os = \\\"horizon\\\", target_arch = \\\"arm\\\")))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"haiku\\\", target_os = \\\"redox\\\", target_os = \\\"nto\\\", target_os = \\\"aix\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"ios\\\", target_os = \\\"visionos\\\", target_os = \\\"watchos\\\", target_os = \\\"tvos\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"openbsd\\\", target_os = \\\"vita\\\", target_os = \\\"emscripten\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"netbsd\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"solaris\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"vxworks\\\")\"},{\"default_features\":false,\"name\":\"r-efi\",\"req\":\"^5.1\",\"target\":\"cfg(all(target_os = \\\"uefi\\\", getrandom_backend = \\\"efi_rng\\\"))\"},{\"default_features\":false,\"name\":\"wasip2\",\"req\":\"^1\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"wasi\\\", target_env = \\\"p2\\\"))\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.98\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"}],\"features\":{\"std\":[],\"wasm_js\":[\"dep:wasm-bindgen\",\"dep:js-sys\"]}}", + "getrandom_0.4.2": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.77\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"), target_feature = \\\"atomics\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), not(any(all(target_os = \\\"linux\\\", target_env = \\\"\\\"), getrandom_backend = \\\"custom\\\", getrandom_backend = \\\"linux_raw\\\", getrandom_backend = \\\"rdrand\\\", getrandom_backend = \\\"rndr\\\"))))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"hurd\\\", target_os = \\\"illumos\\\", target_os = \\\"cygwin\\\", all(target_os = \\\"horizon\\\", target_arch = \\\"arm\\\")))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"haiku\\\", target_os = \\\"redox\\\", target_os = \\\"nto\\\", target_os = \\\"aix\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"ios\\\", target_os = \\\"visionos\\\", target_os = \\\"watchos\\\", target_os = \\\"tvos\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"openbsd\\\", target_os = \\\"vita\\\", target_os = \\\"emscripten\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"netbsd\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"solaris\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"vxworks\\\")\"},{\"default_features\":false,\"name\":\"r-efi\",\"req\":\"^6\",\"target\":\"cfg(all(target_os = \\\"uefi\\\", getrandom_backend = \\\"efi_rng\\\"))\"},{\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.10.0\"},{\"default_features\":false,\"name\":\"wasip2\",\"req\":\"^1\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"wasi\\\", target_env = \\\"p2\\\"))\"},{\"name\":\"wasip3\",\"req\":\"^0.4\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"wasi\\\", target_env = \\\"p3\\\"))\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.98\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"}],\"features\":{\"std\":[],\"sys_rng\":[\"dep:rand_core\"],\"wasm_js\":[\"dep:wasm-bindgen\",\"dep:js-sys\"]}}", "gif_0.14.1": "{\"dependencies\":[{\"name\":\"color_quant\",\"optional\":true,\"req\":\"^1.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7.0\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"png\",\"req\":\"^0.18.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.10.0\"},{\"name\":\"weezl\",\"req\":\"^0.1.10\"}],\"features\":{\"color_quant\":[\"dep:color_quant\"],\"default\":[\"raii_no_panic\",\"std\",\"color_quant\"],\"raii_no_panic\":[],\"std\":[]}}", "gimli_0.32.3": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"fallible-iterator\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"optional\":true,\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"test-assembler\",\"req\":\"^0.1.3\"}],\"features\":{\"default\":[\"read-all\",\"write\"],\"endian-reader\":[\"read\",\"dep:stable_deref_trait\"],\"fallible-iterator\":[\"dep:fallible-iterator\"],\"read\":[\"read-core\"],\"read-all\":[\"read\",\"std\",\"fallible-iterator\",\"endian-reader\"],\"read-core\":[],\"rustc-dep-of-std\":[\"dep:core\",\"dep:alloc\"],\"std\":[\"fallible-iterator?/std\",\"stable_deref_trait?/std\"],\"write\":[\"dep:indexmap\"]}}", "git+https://github.com/dzbarsky/rules_rust?rev=b56cbaa8465e74127f1ea216f813cd377295ad81#b56cbaa8465e74127f1ea216f813cd377295ad81_runfiles": "{\"dependencies\":[],\"features\":{},\"strip_prefix\":\"\"}", @@ -879,6 +891,8 @@ "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch#9b2ad1298408c45918ee9f8241a6f95498cdbed2_ratatui": "{\"dependencies\":[{\"name\":\"bitflags\"},{\"name\":\"cassowary\"},{\"name\":\"compact_str\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"document-features\",\"optional\":true},{\"name\":\"indoc\"},{\"name\":\"instability\"},{\"name\":\"itertools\"},{\"name\":\"lru\"},{\"default_features\":true,\"features\":[],\"name\":\"palette\",\"optional\":true},{\"name\":\"paste\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"strum\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"termwiz\",\"optional\":true},{\"default_features\":true,\"features\":[\"local-offset\"],\"name\":\"time\",\"optional\":true},{\"name\":\"unicode-segmentation\"},{\"name\":\"unicode-truncate\"},{\"name\":\"unicode-width\"},{\"default_features\":true,\"features\":[],\"name\":\"termion\",\"optional\":true,\"target\":\"cfg(not(windows))\"}],\"features\":{\"all-widgets\":[\"widget-calendar\"],\"crossterm\":[\"dep:crossterm\"],\"default\":[\"crossterm\",\"underline-color\"],\"macros\":[],\"palette\":[\"dep:palette\"],\"scrolling-regions\":[],\"serde\":[\"dep:serde\",\"bitflags/serde\",\"compact_str/serde\"],\"termion\":[\"dep:termion\"],\"termwiz\":[\"dep:termwiz\"],\"underline-color\":[\"dep:crossterm\"],\"unstable\":[\"unstable-rendered-line-info\",\"unstable-widget-ref\",\"unstable-backend-writer\"],\"unstable-backend-writer\":[],\"unstable-rendered-line-info\":[],\"unstable-widget-ref\":[],\"widget-calendar\":[\"dep:time\"]},\"strip_prefix\":\"\"}", "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=132f5b39c862e3a970f731d709608b3e6276d5f6#132f5b39c862e3a970f731d709608b3e6276d5f6_tokio-tungstenite": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"optional\":false},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"default_features\":false,\"features\":[],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"tokio-native-tls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tokio-rustls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tungstenite\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"proxy\":[\"tungstenite/proxy\",\"tokio/net\",\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]},\"strip_prefix\":\"\"}", "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=9200079d3b54a1ff51072e24d81fd354f085156f#9200079d3b54a1ff51072e24d81fd354f085156f_tungstenite": "{\"dependencies\":[{\"name\":\"bytes\"},{\"default_features\":true,\"features\":[],\"name\":\"data-encoding\",\"optional\":true},{\"default_features\":false,\"features\":[\"zlib\"],\"name\":\"flate2\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"headers\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"http\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"httparse\",\"optional\":true},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"name\":\"rand\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"sha1\",\"optional\":true},{\"name\":\"thiserror\"},{\"default_features\":true,\"features\":[],\"name\":\"url\",\"optional\":true},{\"name\":\"utf-8\"},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"deflate\":[\"headers\",\"flate2\"],\"handshake\":[\"data-encoding\",\"headers\",\"httparse\",\"sha1\"],\"headers\":[\"http\",\"dep:headers\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"proxy\":[\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]},\"strip_prefix\":\"\"}", + "git+https://github.com/rust-lang/rust-clippy?rev=20ce69b9a63bcd2756cd906fe0964d1e901e042a#20ce69b9a63bcd2756cd906fe0964d1e901e042a_clippy_utils": "{\"dependencies\":[{\"default_features\":false,\"features\":[],\"name\":\"arrayvec\",\"optional\":false},{\"name\":\"itertools\"},{\"name\":\"rustc_apfloat\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":false}],\"features\":{},\"strip_prefix\":\"clippy_utils\"}", + "git2_0.20.4": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.4.13\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"libgit2-sys\",\"req\":\"^0.18.3\"},{\"name\":\"log\",\"req\":\"^0.4.8\"},{\"name\":\"openssl-probe\",\"optional\":true,\"req\":\"^0.1\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.45\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"features\":[\"formatting\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.37\"},{\"name\":\"url\",\"req\":\"^2.5.4\"}],\"features\":{\"default\":[\"ssh\",\"https\"],\"https\":[\"libgit2-sys/https\",\"openssl-sys\",\"openssl-probe\"],\"ssh\":[\"libgit2-sys/ssh\"],\"unstable\":[],\"vendored-libgit2\":[\"libgit2-sys/vendored\"],\"vendored-openssl\":[\"openssl-sys/vendored\",\"libgit2-sys/vendored-openssl\"],\"zlib-ng-compat\":[\"libgit2-sys/zlib-ng-compat\"]}}", "glob_0.3.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"tempdir\",\"req\":\"^0.3\"}],\"features\":{}}", "globset_0.4.18": "{\"dependencies\":[{\"name\":\"aho-corasick\",\"req\":\"^1.1.1\"},{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.3.2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bstr\",\"req\":\"^1.6.2\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3.1\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"features\":[\"std\",\"perf\",\"syntax\",\"meta\",\"nfa\",\"hybrid\"],\"name\":\"regex-automata\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"regex-syntax\",\"req\":\"^0.8.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.188\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.107\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"default\":[\"log\"],\"serde1\":[\"serde\"],\"simd-accel\":[]}}", "gzip-header_1.0.0": "{\"dependencies\":[{\"name\":\"crc32fast\",\"req\":\"^1.2.1\"}],\"features\":{}}", @@ -899,8 +913,8 @@ "hkdf_0.12.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"blobby\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"name\":\"hmac\",\"req\":\"^0.12.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.10\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"}],\"features\":{\"std\":[\"hmac/std\"]}}", "hmac_0.12.1": "{\"dependencies\":[{\"features\":[\"mac\"],\"name\":\"digest\",\"req\":\"^0.10.3\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"md-5\",\"req\":\"^0.10\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha-1\",\"req\":\"^0.10\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"streebog\",\"req\":\"^0.10\"}],\"features\":{\"reset\":[],\"std\":[\"digest/std\"]}}", "home_0.5.12": "{\"dependencies\":[{\"features\":[\"Win32_Foundation\",\"Win32_UI_Shell\",\"Win32_System_Com\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", + "home_0.5.9": "{\"dependencies\":[{\"features\":[\"Win32_Foundation\",\"Win32_UI_Shell\",\"Win32_System_Com\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "hostname_0.4.2": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(any(unix, target_os = \\\"redox\\\"))\"},{\"kind\":\"dev\",\"name\":\"similar-asserts\",\"req\":\"^1.6.1\"},{\"kind\":\"dev\",\"name\":\"version-sync\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"windows-bindgen\",\"req\":\"^0.65\"},{\"name\":\"windows-link\",\"req\":\"^0.2\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"default\":[],\"set\":[]}}", - "hound_3.5.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cpal\",\"req\":\"^0.2.12\"}],\"features\":{}}", "http-body-util_0.1.3": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1\"},{\"name\":\"http-body\",\"req\":\"^1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"macros\",\"rt\",\"sync\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"}],\"features\":{\"channel\":[\"dep:tokio\"],\"default\":[],\"full\":[\"channel\"]}}", "http-body_1.0.1": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"http\",\"req\":\"^1\"}],\"features\":{}}", "http-range-header_0.4.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.8.3\"}],\"features\":{}}", @@ -911,7 +925,7 @@ "hyper-rustls_0.27.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"http\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"hyper\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"client-legacy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"server-auto\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.4\"},{\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"rustls\",\"req\":\"^0.23\"},{\"default_features\":false,\"features\":[\"tls12\"],\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rustls-pemfile\",\"req\":\"^2\"},{\"name\":\"rustls-platform-verifier\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"tokio\",\"req\":\"^1.0\"},{\"features\":[\"io-std\",\"macros\",\"net\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"tokio-rustls\",\"req\":\"^0.26\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"aws-lc-rs\":[\"rustls/aws_lc_rs\"],\"default\":[\"native-tokio\",\"http1\",\"tls12\",\"logging\",\"aws-lc-rs\"],\"fips\":[\"aws-lc-rs\",\"rustls/fips\"],\"http1\":[\"hyper-util/http1\"],\"http2\":[\"hyper-util/http2\"],\"logging\":[\"log\",\"tokio-rustls/logging\",\"rustls/logging\"],\"native-tokio\":[\"rustls-native-certs\"],\"ring\":[\"rustls/ring\"],\"tls12\":[\"tokio-rustls/tls12\",\"rustls/tls12\"],\"webpki-tokio\":[\"webpki-roots\"]}}", "hyper-timeout_0.5.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"name\":\"hyper\",\"req\":\"^1.1\"},{\"features\":[\"http1\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1\"},{\"kind\":\"dev\",\"name\":\"hyper-tls\",\"req\":\"^0.6\"},{\"features\":[\"client-legacy\",\"http1\"],\"name\":\"hyper-util\",\"req\":\"^0.1.10\"},{\"features\":[\"client-legacy\",\"http1\",\"server\",\"server-graceful\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.10\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"name\":\"tokio\",\"req\":\"^1.35\"},{\"features\":[\"io-std\",\"io-util\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.35\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"}],\"features\":{}}", "hyper-tls_0.6.0": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"client-legacy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1.0\"},{\"features\":[\"http1\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.0\"},{\"name\":\"native-tls\",\"req\":\"^0.2.1\"},{\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"io-std\",\"macros\",\"io-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"name\":\"tokio-native-tls\",\"req\":\"^0.3\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"}],\"features\":{\"alpn\":[\"native-tls/alpn\"],\"vendored\":[\"native-tls/vendored\"]}}", - "hyper-util_0.1.19": "{\"dependencies\":[{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"bytes\",\"req\":\"^1.7.1\"},{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.16\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.16\"},{\"name\":\"http\",\"req\":\"^1.0\"},{\"name\":\"http-body\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"name\":\"hyper\",\"req\":\"^1.8.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.4.0\"},{\"name\":\"ipnet\",\"optional\":true,\"req\":\"^2.9\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"percent-encoding\",\"optional\":true,\"req\":\"^2.3\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.4\"},{\"kind\":\"dev\",\"name\":\"pnet_datalink\",\"req\":\"^0.35.0\",\"target\":\"cfg(any(target_os = \\\"linux\\\", target_os = \\\"macos\\\"))\"},{\"kind\":\"dev\",\"name\":\"pretty_env_logger\",\"req\":\"^0.5\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\">=0.5.9, <0.7\"},{\"name\":\"system-configuration\",\"optional\":true,\"req\":\">=0.5, <0.7\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"macros\",\"test-util\",\"signal\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"name\":\"tower-layer\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"tower-service\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"tower-test\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"windows-registry\",\"optional\":true,\"req\":\">=0.3, <0.7\",\"target\":\"cfg(windows)\"}],\"features\":{\"__internal_happy_eyeballs_tests\":[],\"client\":[\"hyper/client\",\"tokio/net\",\"dep:tracing\",\"dep:futures-channel\",\"dep:tower-service\"],\"client-legacy\":[\"client\",\"dep:socket2\",\"tokio/sync\",\"dep:libc\",\"dep:futures-util\"],\"client-pool\":[\"client\",\"dep:futures-util\",\"dep:tower-layer\"],\"client-proxy\":[\"client\",\"dep:base64\",\"dep:ipnet\",\"dep:percent-encoding\"],\"client-proxy-system\":[\"dep:system-configuration\",\"dep:windows-registry\"],\"default\":[],\"full\":[\"client\",\"client-legacy\",\"client-pool\",\"client-proxy\",\"client-proxy-system\",\"server\",\"server-auto\",\"server-graceful\",\"service\",\"http1\",\"http2\",\"tokio\",\"tracing\"],\"http1\":[\"hyper/http1\"],\"http2\":[\"hyper/http2\"],\"server\":[\"hyper/server\"],\"server-auto\":[\"server\",\"http1\",\"http2\"],\"server-graceful\":[\"server\",\"tokio/sync\"],\"service\":[\"dep:tower-service\"],\"tokio\":[\"dep:tokio\",\"tokio/rt\",\"tokio/time\"],\"tracing\":[\"dep:tracing\"]}}", + "hyper-util_0.1.20": "{\"dependencies\":[{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"bytes\",\"req\":\"^1.7.1\"},{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.16\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.16\"},{\"name\":\"http\",\"req\":\"^1.0\"},{\"name\":\"http-body\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"name\":\"hyper\",\"req\":\"^1.8.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.4.0\"},{\"name\":\"ipnet\",\"optional\":true,\"req\":\"^2.9\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"percent-encoding\",\"optional\":true,\"req\":\"^2.3\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.4\"},{\"kind\":\"dev\",\"name\":\"pnet_datalink\",\"req\":\"^0.35.0\",\"target\":\"cfg(any(target_os = \\\"linux\\\", target_os = \\\"macos\\\"))\"},{\"kind\":\"dev\",\"name\":\"pretty_env_logger\",\"req\":\"^0.5\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\">=0.5.9, <0.7\"},{\"name\":\"system-configuration\",\"optional\":true,\"req\":\"^0.7\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"macros\",\"test-util\",\"signal\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"name\":\"tower-layer\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"tower-service\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"tower-test\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"windows-registry\",\"optional\":true,\"req\":\">=0.3, <0.7\",\"target\":\"cfg(windows)\"}],\"features\":{\"__internal_happy_eyeballs_tests\":[],\"client\":[\"hyper/client\",\"tokio/net\",\"dep:tracing\",\"dep:futures-channel\",\"dep:tower-service\"],\"client-legacy\":[\"client\",\"dep:socket2\",\"tokio/sync\",\"dep:libc\",\"dep:futures-util\"],\"client-pool\":[\"client\",\"dep:futures-util\",\"dep:tower-layer\"],\"client-proxy\":[\"client\",\"dep:base64\",\"dep:ipnet\",\"dep:percent-encoding\"],\"client-proxy-system\":[\"dep:system-configuration\",\"dep:windows-registry\"],\"default\":[],\"full\":[\"client\",\"client-legacy\",\"client-pool\",\"client-proxy\",\"client-proxy-system\",\"server\",\"server-auto\",\"server-graceful\",\"service\",\"http1\",\"http2\",\"tokio\",\"tracing\"],\"http1\":[\"hyper/http1\"],\"http2\":[\"hyper/http2\"],\"server\":[\"hyper/server\"],\"server-auto\":[\"server\",\"http1\",\"http2\"],\"server-graceful\":[\"server\",\"tokio/sync\"],\"service\":[\"dep:tower-service\"],\"tokio\":[\"dep:tokio\",\"tokio/rt\",\"tokio/time\"],\"tracing\":[\"dep:tracing\"]}}", "hyper_1.8.1": "{\"dependencies\":[{\"name\":\"atomic-waker\",\"optional\":true,\"req\":\"^1.1.2\"},{\"name\":\"bytes\",\"req\":\"^1.2\"},{\"kind\":\"dev\",\"name\":\"form_urlencoded\",\"req\":\"^1\"},{\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3\"},{\"features\":[\"sink\"],\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3\"},{\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3.31\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4.2\"},{\"name\":\"http\",\"req\":\"^1\"},{\"name\":\"http-body\",\"req\":\"^1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"name\":\"httparse\",\"optional\":true,\"req\":\"^1.9\"},{\"name\":\"httpdate\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"itoa\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"pin-project-lite\",\"optional\":true,\"req\":\"^0.2.4\"},{\"kind\":\"dev\",\"name\":\"pin-project-lite\",\"req\":\"^0.2.4\"},{\"name\":\"pin-utils\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"pretty_env_logger\",\"req\":\"^0.5\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"const_generics\",\"const_new\"],\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1.12\"},{\"kind\":\"dev\",\"name\":\"spmc\",\"req\":\"^0.3\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"fs\",\"macros\",\"net\",\"io-std\",\"io-util\",\"rt\",\"rt-multi-thread\",\"sync\",\"time\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7.10\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"want\",\"optional\":true,\"req\":\"^0.3\"}],\"features\":{\"capi\":[],\"client\":[\"dep:want\",\"dep:pin-project-lite\",\"dep:smallvec\"],\"default\":[],\"ffi\":[\"dep:http-body-util\",\"dep:futures-util\"],\"full\":[\"client\",\"http1\",\"http2\",\"server\"],\"http1\":[\"dep:atomic-waker\",\"dep:futures-channel\",\"dep:futures-core\",\"dep:httparse\",\"dep:itoa\",\"dep:pin-utils\"],\"http2\":[\"dep:futures-channel\",\"dep:futures-core\",\"dep:h2\"],\"nightly\":[],\"server\":[\"dep:httpdate\",\"dep:pin-project-lite\",\"dep:smallvec\"],\"tracing\":[\"dep:tracing\"]}}", "i18n-config_0.4.8": "{\"dependencies\":[{\"name\":\"basic-toml\",\"req\":\"^0.1\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"name\":\"thiserror\",\"req\":\"^1.0\"},{\"features\":[\"serde\"],\"name\":\"unic-langid\",\"req\":\"^0.9\"}],\"features\":{}}", "i18n-embed-fl_0.9.4": "{\"dependencies\":[{\"name\":\"dashmap\",\"optional\":true,\"req\":\"^6.0\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"find-crate\",\"req\":\"^0.6\"},{\"name\":\"fluent\",\"req\":\"^0.16\"},{\"name\":\"fluent-syntax\",\"req\":\"^0.11\"},{\"name\":\"i18n-config\",\"req\":\"^0.4.7\"},{\"features\":[\"fluent-system\",\"filesystem-assets\"],\"name\":\"i18n-embed\",\"req\":\"^0.15.4\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4\"},{\"name\":\"proc-macro-error2\",\"req\":\"^2.0.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rust-embed\",\"req\":\"^8.0\"},{\"name\":\"strsim\",\"req\":\"^0.11\"},{\"features\":[\"derive\",\"proc-macro\",\"parsing\",\"printing\",\"extra-traits\",\"full\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"name\":\"unic-langid\",\"req\":\"^0.9\"}],\"features\":{\"dashmap\":[\"dep:dashmap\"]}}", @@ -932,9 +946,11 @@ "icu_properties_2.1.2": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"icu_collections\",\"req\":\"~2.1.1\"},{\"default_features\":false,\"features\":[\"zerovec\"],\"name\":\"icu_locale_core\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"icu_properties_data\",\"optional\":true,\"req\":\"~2.1.2\"},{\"default_features\":false,\"name\":\"icu_provider\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"unicode-bidi\",\"optional\":true,\"req\":\"^0.3.11\"},{\"default_features\":false,\"features\":[\"yoke\",\"zerofrom\"],\"name\":\"zerotrie\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"derive\",\"yoke\"],\"name\":\"zerovec\",\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"zerovec/alloc\",\"icu_collections/alloc\",\"serde?/alloc\"],\"compiled_data\":[\"dep:icu_properties_data\",\"icu_provider/baked\"],\"datagen\":[\"serde\",\"dep:databake\",\"zerovec/databake\",\"icu_collections/databake\",\"icu_locale_core/databake\",\"zerotrie/databake\",\"icu_provider/export\"],\"default\":[\"compiled_data\"],\"serde\":[\"dep:serde\",\"icu_locale_core/serde\",\"zerovec/serde\",\"icu_collections/serde\",\"icu_provider/serde\",\"zerotrie/serde\"],\"unicode_bidi\":[\"dep:unicode-bidi\"]}}", "icu_properties_data_2.1.2": "{\"dependencies\":[],\"features\":{}}", "icu_provider_2.1.1": "{\"dependencies\":[{\"name\":\"bincode\",\"optional\":true,\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"name\":\"erased-serde\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"icu_locale_core\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"},{\"default_features\":false,\"name\":\"postcard\",\"optional\":true,\"req\":\"^1.0.3\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.45\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"optional\":true,\"req\":\"^1.2.0\"},{\"default_features\":false,\"name\":\"writeable\",\"optional\":true,\"req\":\"^0.6.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"yoke\",\"req\":\"^0.8.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"zerofrom\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"zerotrie\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"zerovec\",\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"icu_locale_core/alloc\",\"serde?/alloc\",\"yoke/alloc\",\"zerofrom/alloc\",\"zerovec/alloc\",\"zerotrie?/alloc\",\"dep:stable_deref_trait\",\"dep:writeable\"],\"baked\":[\"dep:zerotrie\",\"dep:writeable\"],\"deserialize_bincode_1\":[\"serde\",\"dep:bincode\",\"std\"],\"deserialize_json\":[\"serde\",\"dep:serde_json\"],\"deserialize_postcard_1\":[\"serde\",\"dep:postcard\"],\"export\":[\"serde\",\"dep:erased-serde\",\"dep:databake\",\"std\",\"sync\",\"dep:postcard\",\"zerovec/databake\"],\"logging\":[\"dep:log\"],\"serde\":[\"dep:serde\",\"yoke/serde\"],\"std\":[\"alloc\"],\"sync\":[],\"zerotrie\":[]}}", + "id-arena_2.3.0": "{\"dependencies\":[{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.0.3\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "ident_case_1.0.1": "{\"dependencies\":[],\"features\":{}}", "idna_1.1.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.3\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1\"},{\"name\":\"idna_adapter\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"const_generics\"],\"name\":\"smallvec\",\"req\":\"^1.13.1\"},{\"kind\":\"dev\",\"name\":\"tester\",\"req\":\"^0.9\"},{\"name\":\"utf8_iter\",\"req\":\"^1.0.4\"}],\"features\":{\"alloc\":[],\"compiled_data\":[\"idna_adapter/compiled_data\"],\"default\":[\"std\",\"compiled_data\"],\"std\":[\"alloc\"]}}", "idna_adapter_1.2.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"icu_normalizer\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"icu_properties\",\"req\":\"^2\"}],\"features\":{\"compiled_data\":[\"icu_normalizer/compiled_data\",\"icu_properties/compiled_data\"]}}", + "if_chain_1.0.3": "{\"dependencies\":[],\"features\":{}}", "ignore_0.4.25": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"bstr\",\"req\":\"^1.6.2\"},{\"kind\":\"dev\",\"name\":\"crossbeam-channel\",\"req\":\"^0.5.15\"},{\"name\":\"crossbeam-deque\",\"req\":\"^0.8.3\"},{\"name\":\"globset\",\"req\":\"^0.4.18\"},{\"name\":\"log\",\"req\":\"^0.4.20\"},{\"name\":\"memchr\",\"req\":\"^2.6.3\"},{\"default_features\":false,\"features\":[\"std\",\"perf\",\"syntax\",\"meta\",\"nfa\",\"hybrid\",\"dfa-onepass\"],\"name\":\"regex-automata\",\"req\":\"^0.4.0\"},{\"name\":\"same-file\",\"req\":\"^1.0.6\"},{\"name\":\"walkdir\",\"req\":\"^2.4.0\"},{\"name\":\"winapi-util\",\"req\":\"^0.1.2\",\"target\":\"cfg(windows)\"}],\"features\":{\"simd-accel\":[]}}", "image-webp_0.2.4": "{\"dependencies\":[{\"name\":\"byteorder-lite\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.14\"},{\"kind\":\"dev\",\"name\":\"png\",\"req\":\"^0.17.12\"},{\"name\":\"quick-error\",\"req\":\"^2.0.1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"webp\",\"req\":\"^0.3.0\"}],\"features\":{\"_benchmarks\":[]}}", "image_0.25.9": "{\"dependencies\":[{\"features\":[\"extern_crate_alloc\"],\"name\":\"bytemuck\",\"req\":\"^1.8.0\"},{\"name\":\"byteorder-lite\",\"req\":\"^0.1.0\"},{\"name\":\"color_quant\",\"optional\":true,\"req\":\"^1.1\"},{\"kind\":\"dev\",\"name\":\"crc32fast\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\"},{\"name\":\"dav1d\",\"optional\":true,\"req\":\"^0.10.3\"},{\"default_features\":false,\"name\":\"exr\",\"optional\":true,\"req\":\"^1.74.0\"},{\"name\":\"gif\",\"optional\":true,\"req\":\"^0.14.0\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3\"},{\"name\":\"image-webp\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"moxcms\",\"req\":\"^0.7.4\"},{\"name\":\"mp4parse\",\"optional\":true,\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"num-complex\",\"req\":\"^0.4\"},{\"name\":\"num-traits\",\"req\":\"^0.2.0\"},{\"name\":\"png\",\"optional\":true,\"req\":\"^0.18.0\"},{\"name\":\"qoi\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"ravif\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.7.0\"},{\"default_features\":false,\"name\":\"rgb\",\"optional\":true,\"req\":\"^0.8.48\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.214\"},{\"name\":\"tiff\",\"optional\":true,\"req\":\"^0.10.3\"},{\"default_features\":false,\"name\":\"zune-core\",\"optional\":true,\"req\":\"^0.5.0\"},{\"name\":\"zune-jpeg\",\"optional\":true,\"req\":\"^0.5.5\"}],\"features\":{\"avif\":[\"dep:ravif\",\"dep:rgb\"],\"avif-native\":[\"dep:mp4parse\",\"dep:dav1d\"],\"benchmarks\":[],\"bmp\":[],\"color_quant\":[\"dep:color_quant\"],\"dds\":[],\"default\":[\"rayon\",\"default-formats\"],\"default-formats\":[\"avif\",\"bmp\",\"dds\",\"exr\",\"ff\",\"gif\",\"hdr\",\"ico\",\"jpeg\",\"png\",\"pnm\",\"qoi\",\"tga\",\"tiff\",\"webp\"],\"exr\":[\"dep:exr\"],\"ff\":[],\"gif\":[\"dep:gif\",\"dep:color_quant\"],\"hdr\":[],\"ico\":[\"bmp\",\"png\"],\"jpeg\":[\"dep:zune-core\",\"dep:zune-jpeg\"],\"nasm\":[\"ravif?/asm\"],\"png\":[\"dep:png\"],\"pnm\":[],\"qoi\":[\"dep:qoi\"],\"rayon\":[\"dep:rayon\",\"ravif?/threading\",\"exr?/rayon\"],\"serde\":[\"dep:serde\"],\"tga\":[],\"tiff\":[\"dep:tiff\"],\"webp\":[\"dep:image-webp\"]}}", @@ -961,12 +977,15 @@ "is_ci_1.2.0": "{\"dependencies\":[],\"features\":{}}", "is_terminal_polyfill_1.70.2": "{\"dependencies\":[],\"features\":{\"default\":[]}}", "itertools_0.10.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"= 0\"},{\"default_features\":false,\"name\":\"either\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"permutohedron\",\"req\":\"^0.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"use_std\"],\"use_alloc\":[],\"use_std\":[\"use_alloc\",\"either/use_std\"]}}", + "itertools_0.12.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"either\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"permutohedron\",\"req\":\"^0.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"use_std\"],\"use_alloc\":[],\"use_std\":[\"use_alloc\",\"either/use_std\"]}}", "itertools_0.13.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"either\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"permutohedron\",\"req\":\"^0.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"use_std\"],\"use_alloc\":[],\"use_std\":[\"use_alloc\",\"either/use_std\"]}}", "itertools_0.14.0": "{\"dependencies\":[{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"either\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"permutohedron\",\"req\":\"^0.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"use_std\"],\"use_alloc\":[],\"use_std\":[\"use_alloc\",\"either/use_std\"]}}", "itoa_1.0.17": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1\"}],\"features\":{}}", "ixdtf_0.6.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"serde-json-core\",\"req\":\"^0.6.0\"}],\"features\":{\"default\":[\"duration\"],\"duration\":[]}}", "jiff-static_0.2.18": "{\"dependencies\":[{\"name\":\"jiff-tzdb\",\"optional\":true,\"req\":\"^0.1.4\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.93\"},{\"name\":\"quote\",\"req\":\"^1.0.38\"},{\"name\":\"syn\",\"req\":\"^2.0.98\"}],\"features\":{\"default\":[],\"perf-inline\":[],\"tz-fat\":[],\"tzdb\":[\"dep:jiff-tzdb\"]}}", + "jiff-static_0.2.23": "{\"dependencies\":[{\"name\":\"jiff-tzdb\",\"optional\":true,\"req\":\"^0.1.6\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.93\"},{\"name\":\"quote\",\"req\":\"^1.0.38\"},{\"name\":\"syn\",\"req\":\"^2.0.98\"}],\"features\":{\"default\":[],\"perf-inline\":[],\"tz-fat\":[],\"tzdb\":[\"dep:jiff-tzdb\"]}}", "jiff_0.2.18": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.81\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"kind\":\"dev\",\"name\":\"chrono-tz\",\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"hifitime\",\"req\":\"^3.9.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"humantime\",\"req\":\"^2.1.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.39.0\"},{\"name\":\"jiff-static\",\"req\":\"=0.2.18\",\"target\":\"cfg(any())\"},{\"name\":\"jiff-static\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"jiff-tzdb\",\"optional\":true,\"req\":\"^0.1.5\"},{\"name\":\"jiff-tzdb-platform\",\"optional\":true,\"req\":\"^0.1.3\",\"target\":\"cfg(any(windows, target_family = \\\"wasm\\\"))\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.50\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.21\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"req\":\"^1.10.0\",\"target\":\"cfg(not(target_has_atomic = \\\"ptr\\\"))\"},{\"default_features\":false,\"name\":\"portable-atomic-util\",\"req\":\"^0.2.4\",\"target\":\"cfg(not(target_has_atomic = \\\"ptr\\\"))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.203\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.117\"},{\"kind\":\"dev\",\"name\":\"serde_yaml\",\"req\":\"^0.9.34\"},{\"kind\":\"dev\",\"name\":\"tabwriter\",\"req\":\"^1.4.0\"},{\"features\":[\"local-offset\",\"macros\",\"parsing\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.36\"},{\"kind\":\"dev\",\"name\":\"tzfile\",\"req\":\"^0.1.3\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5.0\"},{\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.70\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"features\":[\"Win32_Foundation\",\"Win32_System_Time\"],\"name\":\"windows-sys\",\"optional\":true,\"req\":\">=0.52.0, <=0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\",\"portable-atomic-util/alloc\"],\"default\":[\"std\",\"tz-system\",\"tz-fat\",\"tzdb-bundle-platform\",\"tzdb-zoneinfo\",\"tzdb-concatenated\",\"perf-inline\"],\"js\":[\"dep:wasm-bindgen\",\"dep:js-sys\"],\"logging\":[\"dep:log\"],\"perf-inline\":[],\"serde\":[\"dep:serde_core\"],\"static\":[\"static-tz\",\"jiff-static?/tzdb\"],\"static-tz\":[\"dep:jiff-static\"],\"std\":[\"alloc\",\"log?/std\",\"serde_core?/std\"],\"tz-fat\":[\"jiff-static?/tz-fat\"],\"tz-system\":[\"std\",\"dep:windows-sys\"],\"tzdb-bundle-always\":[\"dep:jiff-tzdb\",\"alloc\"],\"tzdb-bundle-platform\":[\"dep:jiff-tzdb-platform\",\"alloc\"],\"tzdb-concatenated\":[\"std\"],\"tzdb-zoneinfo\":[\"std\"]}}", + "jiff_0.2.23": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.81\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"kind\":\"dev\",\"name\":\"chrono-tz\",\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"hifitime\",\"req\":\"^3.9.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"humantime\",\"req\":\"^2.1.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.39.0\"},{\"name\":\"jiff-static\",\"req\":\"=0.2.23\",\"target\":\"cfg(any())\"},{\"name\":\"jiff-static\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"jiff-tzdb\",\"optional\":true,\"req\":\"^0.1.6\"},{\"name\":\"jiff-tzdb-platform\",\"optional\":true,\"req\":\"^0.1.3\",\"target\":\"cfg(any(windows, target_family = \\\"wasm\\\"))\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.50\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.21\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"req\":\"^1.10.0\",\"target\":\"cfg(not(target_has_atomic = \\\"ptr\\\"))\"},{\"default_features\":false,\"name\":\"portable-atomic-util\",\"req\":\"^0.2.4\",\"target\":\"cfg(not(target_has_atomic = \\\"ptr\\\"))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.203\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.117\"},{\"kind\":\"dev\",\"name\":\"serde_yaml\",\"req\":\"^0.9.34\"},{\"kind\":\"dev\",\"name\":\"tabwriter\",\"req\":\"^1.4.0\"},{\"features\":[\"local-offset\",\"macros\",\"parsing\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.36\"},{\"kind\":\"dev\",\"name\":\"time-tz\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"tzfile\",\"req\":\"^0.1.3\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5.0\"},{\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.70\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"features\":[\"Win32_Foundation\",\"Win32_System_Time\"],\"name\":\"windows-sys\",\"optional\":true,\"req\":\">=0.52.0, <=0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\",\"portable-atomic-util/alloc\"],\"default\":[\"std\",\"tz-system\",\"tz-fat\",\"tzdb-bundle-platform\",\"tzdb-zoneinfo\",\"tzdb-concatenated\",\"perf-inline\"],\"js\":[\"dep:wasm-bindgen\",\"dep:js-sys\"],\"logging\":[\"dep:log\"],\"perf-inline\":[],\"serde\":[\"dep:serde_core\"],\"static\":[\"static-tz\",\"jiff-static?/tzdb\"],\"static-tz\":[\"dep:jiff-static\"],\"std\":[\"alloc\",\"log?/std\",\"serde_core?/std\"],\"tz-fat\":[\"jiff-static?/tz-fat\"],\"tz-system\":[\"std\",\"dep:windows-sys\"],\"tzdb-bundle-always\":[\"dep:jiff-tzdb\",\"alloc\"],\"tzdb-bundle-platform\":[\"dep:jiff-tzdb-platform\",\"alloc\"],\"tzdb-concatenated\":[\"std\"],\"tzdb-zoneinfo\":[\"std\"]}}", "jni-sys_0.3.0": "{\"dependencies\":[],\"features\":{}}", "jni_0.21.1": "{\"dependencies\":[{\"name\":\"cesu8\",\"req\":\"^1.1.0\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"combine\",\"req\":\"^4.1.0\"},{\"name\":\"java-locator\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"jni-sys\",\"req\":\"^0.3.0\"},{\"name\":\"libloading\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"name\":\"thiserror\",\"req\":\"^1.0.20\"},{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rusty-fork\",\"req\":\"^0.3.0\"},{\"kind\":\"build\",\"name\":\"walkdir\",\"req\":\"^2\"},{\"features\":[\"Win32_Globalization\"],\"name\":\"windows-sys\",\"req\":\"^0.45.0\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.13.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"invocation\":[\"java-locator\",\"libloading\"]}}", "jobserver_0.1.34": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"getrandom\",\"req\":\"^0.3.2\",\"target\":\"cfg(windows)\"},{\"name\":\"libc\",\"req\":\"^0.2.171\",\"target\":\"cfg(unix)\"},{\"features\":[\"fs\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.28.0\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"}],\"features\":{}}", @@ -980,16 +999,23 @@ "landlock_0.4.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"enumflags2\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"libc\",\"req\":\"^0.2.175\"},{\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.26\"},{\"kind\":\"dev\",\"name\":\"strum_macros\",\"req\":\"^0.26\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"}],\"features\":{}}", "language-tags_0.3.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{}}", "lazy_static_1.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"features\":[\"once\"],\"name\":\"spin\",\"optional\":true,\"req\":\"^0.9.8\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"}],\"features\":{\"spin_no_std\":[\"spin\"]}}", + "leb128fmt_0.1.0": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[]}}", "libc_0.2.182": "{\"dependencies\":[{\"name\":\"rustc-std-workspace-core\",\"optional\":true,\"req\":\"^1.0.1\"}],\"features\":{\"align\":[],\"const-extern-fn\":[],\"default\":[\"std\"],\"extra_traits\":[],\"rustc-dep-of-std\":[\"align\",\"rustc-std-workspace-core\"],\"std\":[],\"use_std\":[\"std\"]}}", + "libc_0.2.183": "{\"dependencies\":[{\"name\":\"rustc-std-workspace-core\",\"optional\":true,\"req\":\"^1.0.1\"}],\"features\":{\"align\":[],\"const-extern-fn\":[],\"default\":[\"std\"],\"extra_traits\":[],\"rustc-dep-of-std\":[\"align\",\"rustc-std-workspace-core\"],\"std\":[],\"use_std\":[\"std\"]}}", "libdbus-sys_0.2.7": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1.0.78\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"optional\":true,\"req\":\"^0.3\"}],\"features\":{\"default\":[\"pkg-config\"],\"vendored\":[\"cc\"]}}", + "libgit2-sys_0.18.3+1.9.2": "{\"dependencies\":[{\"features\":[\"parallel\"],\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.43\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"libssh2-sys\",\"optional\":true,\"req\":\"^0.3.0\"},{\"default_features\":false,\"features\":[\"libc\"],\"name\":\"libz-sys\",\"req\":\"^1.1.0\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.45\",\"target\":\"cfg(unix)\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.15\"}],\"features\":{\"https\":[\"openssl-sys\"],\"ssh\":[\"libssh2-sys\"],\"vendored\":[],\"vendored-openssl\":[\"openssl-sys/vendored\"],\"zlib-ng-compat\":[\"libz-sys/zlib-ng\",\"libssh2-sys?/zlib-ng-compat\"]}}", "libloading_0.8.9": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"},{\"name\":\"windows-link\",\"req\":\"^0.2\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_Foundation\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "libm_0.2.16": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"no-panic\",\"req\":\"^0.1.35\"}],\"features\":{\"arch\":[],\"default\":[\"arch\"],\"force-soft-floats\":[],\"unstable\":[\"unstable-intrinsics\",\"unstable-float\"],\"unstable-float\":[],\"unstable-intrinsics\":[],\"unstable-public-internals\":[]}}", "libredox_0.1.12": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"name\":\"ioslice\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"redox_syscall\",\"optional\":true,\"req\":\"^0.7\"}],\"features\":{\"call\":[],\"default\":[\"call\",\"std\",\"redox_syscall\"],\"mkns\":[\"ioslice\"],\"std\":[]}}", + "libredox_0.1.14": "{\"dependencies\":[{\"name\":\"bitflags\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ioslice\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"plain\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"redox_syscall\",\"optional\":true,\"req\":\"^0.7\"}],\"features\":{\"base\":[\"libc\"],\"call\":[\"base\"],\"default\":[\"base\",\"call\",\"std\",\"redox_syscall\",\"protocol\"],\"mkns\":[\"ioslice\"],\"protocol\":[\"plain\",\"bitflags\",\"redox_syscall\"],\"std\":[\"base\"]}}", "libsqlite3-sys_0.30.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.69\"},{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1.1.6\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.103\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"optional\":true,\"req\":\"^0.3.19\"},{\"kind\":\"build\",\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2.20\"},{\"default_features\":false,\"kind\":\"build\",\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0.36\"},{\"features\":[\"full\",\"extra-traits\",\"visit-mut\"],\"kind\":\"build\",\"name\":\"syn\",\"optional\":true,\"req\":\"^2.0.72\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"optional\":true,\"req\":\"^0.2.15\"}],\"features\":{\"buildtime_bindgen\":[\"bindgen\",\"pkg-config\",\"vcpkg\"],\"bundled\":[\"cc\",\"bundled_bindings\"],\"bundled-sqlcipher\":[\"bundled\"],\"bundled-sqlcipher-vendored-openssl\":[\"bundled-sqlcipher\",\"openssl-sys/vendored\"],\"bundled-windows\":[\"cc\",\"bundled_bindings\"],\"bundled_bindings\":[],\"default\":[\"min_sqlite_version_3_14_0\"],\"in_gecko\":[],\"loadable_extension\":[\"prettyplease\",\"quote\",\"syn\"],\"min_sqlite_version_3_14_0\":[\"pkg-config\",\"vcpkg\"],\"preupdate_hook\":[\"buildtime_bindgen\"],\"session\":[\"preupdate_hook\",\"buildtime_bindgen\"],\"sqlcipher\":[],\"unlock_notify\":[],\"wasm32-wasi-vfs\":[],\"with-asan\":[]}}", + "libssh2-sys_0.3.1": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.25\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"libc\"],\"name\":\"libz-sys\",\"req\":\"^1.1.0\"},{\"name\":\"openssl-sys\",\"req\":\"^0.9.35\",\"target\":\"cfg(unix)\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.35\",\"target\":\"cfg(windows)\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.11\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2\",\"target\":\"cfg(target_env = \\\"msvc\\\")\"}],\"features\":{\"openssl-on-win32\":[\"openssl-sys\"],\"vendored-openssl\":[\"openssl-sys/vendored\"],\"zlib-ng-compat\":[\"libz-sys/zlib-ng\"]}}", "libz-sys_1.1.23": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.98\"},{\"kind\":\"build\",\"name\":\"cmake\",\"optional\":true,\"req\":\"^0.1.50\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.43\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.11\"}],\"features\":{\"asm\":[],\"default\":[\"libc\",\"stock-zlib\"],\"static\":[],\"stock-zlib\":[],\"zlib-ng\":[\"libc\",\"cmake\"],\"zlib-ng-no-cmake-experimental-community-maintained\":[\"libc\"]}}", + "libz-sys_1.1.25": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.98\"},{\"kind\":\"build\",\"name\":\"cmake\",\"optional\":true,\"req\":\"^0.1.50\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.43\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.11\"}],\"features\":{\"asm\":[],\"default\":[\"libc\",\"stock-zlib\"],\"static\":[],\"stock-zlib\":[],\"zlib-ng\":[\"libc\",\"cmake\"],\"zlib-ng-no-cmake-experimental-community-maintained\":[\"libc\"]}}", "linked-hash-map_0.5.6": "{\"dependencies\":[{\"name\":\"heapsize\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"heapsize_impl\":[\"heapsize\"],\"nightly\":[],\"serde_impl\":[\"serde\"]}}", "linux-keyutils_0.2.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4\"},{\"default_features\":false,\"features\":[\"std\",\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.4.11\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.132\"},{\"kind\":\"dev\",\"name\":\"zeroize\",\"req\":\"^1.5.7\"}],\"features\":{\"default\":[],\"std\":[\"bitflags/std\"]}}", "linux-raw-sys_0.11.0": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.100\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"}],\"features\":{\"auxvec\":[],\"bootparam\":[],\"btrfs\":[],\"default\":[\"std\",\"general\",\"errno\"],\"elf\":[],\"elf_uapi\":[],\"errno\":[],\"general\":[],\"if_arp\":[],\"if_ether\":[],\"if_packet\":[],\"image\":[],\"io_uring\":[],\"ioctl\":[],\"landlock\":[],\"loop_device\":[],\"mempolicy\":[],\"net\":[],\"netlink\":[],\"no_std\":[],\"prctl\":[],\"ptrace\":[],\"rustc-dep-of-std\":[\"core\",\"no_std\"],\"std\":[],\"system\":[],\"xdp\":[]}}", + "linux-raw-sys_0.12.1": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.100\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"}],\"features\":{\"auxvec\":[],\"bootparam\":[],\"btrfs\":[],\"default\":[\"std\",\"general\",\"errno\"],\"elf\":[],\"elf_uapi\":[],\"errno\":[],\"general\":[],\"if_arp\":[],\"if_ether\":[],\"if_packet\":[],\"if_tun\":[],\"image\":[],\"io_uring\":[],\"ioctl\":[],\"landlock\":[],\"loop_device\":[],\"mempolicy\":[],\"net\":[],\"netlink\":[],\"no_std\":[],\"prctl\":[],\"ptrace\":[],\"rustc-dep-of-std\":[\"core\",\"no_std\"],\"std\":[],\"system\":[],\"vm_sockets\":[],\"xdp\":[]}}", "linux-raw-sys_0.4.15": "{\"dependencies\":[{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.49\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.100\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"}],\"features\":{\"bootparam\":[],\"btrfs\":[],\"default\":[\"std\",\"general\",\"errno\"],\"elf\":[],\"elf_uapi\":[],\"errno\":[],\"general\":[],\"if_arp\":[],\"if_ether\":[],\"if_packet\":[],\"io_uring\":[],\"ioctl\":[],\"landlock\":[],\"loop_device\":[],\"mempolicy\":[],\"net\":[],\"netlink\":[],\"no_std\":[],\"prctl\":[],\"ptrace\":[],\"rustc-dep-of-std\":[\"core\",\"compiler_builtins\",\"no_std\"],\"std\":[],\"system\":[],\"xdp\":[]}}", "litemap_0.8.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"use-std\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"features\":[\"validation\"],\"kind\":\"dev\",\"name\":\"rkyv\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.0\"}],\"features\":{\"alloc\":[],\"databake\":[\"dep:databake\"],\"default\":[\"alloc\"],\"serde\":[\"dep:serde_core\",\"alloc\"],\"testing\":[\"alloc\"],\"yoke\":[\"dep:yoke\"]}}", "local-waker_0.1.4": "{\"dependencies\":[],\"features\":{}}", @@ -1012,6 +1038,7 @@ "md-5_0.10.6": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"name\":\"md5-asm\",\"optional\":true,\"req\":\"^0.5\",\"target\":\"cfg(any(target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"}],\"features\":{\"asm\":[\"md5-asm\"],\"default\":[\"std\"],\"force-soft\":[],\"loongarch64_asm\":[],\"oid\":[\"digest/oid\"],\"std\":[\"digest/std\"]}}", "md5_0.8.0": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "memchr_2.7.6": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"libc\":[],\"logging\":[\"dep:log\"],\"rustc-dep-of-std\":[\"core\"],\"std\":[\"alloc\"],\"use_std\":[\"std\"]}}", + "memchr_2.8.0": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"libc\":[],\"logging\":[\"dep:log\"],\"rustc-dep-of-std\":[\"core\"],\"std\":[\"alloc\"],\"use_std\":[\"std\"]}}", "memoffset_0.6.5": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"autocfg\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"}],\"features\":{\"default\":[],\"unstable_const\":[]}}", "memoffset_0.9.1": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"autocfg\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"}],\"features\":{\"default\":[],\"unstable_const\":[],\"unstable_offset_of\":[]}}", "mime_0.3.17": "{\"dependencies\":[],\"features\":{}}", @@ -1019,6 +1046,7 @@ "minimal-lexical_0.2.1": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"compact\":[],\"default\":[\"std\"],\"lint\":[],\"nightly\":[],\"std\":[]}}", "miniz_oxide_0.8.9": "{\"dependencies\":[{\"default_features\":false,\"name\":\"adler2\",\"req\":\"^2.0\"},{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"simd-adler32\",\"optional\":true,\"req\":\"^0.3.3\"}],\"features\":{\"block-boundary\":[],\"default\":[\"with-alloc\"],\"rustc-dep-of-std\":[\"core\",\"alloc\",\"adler2/rustc-dep-of-std\"],\"simd\":[\"simd-adler32\"],\"std\":[],\"with-alloc\":[]}}", "mio_1.1.1": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"libc\",\"req\":\"^0.2.178\",\"target\":\"cfg(target_os = \\\"hermit\\\")\"},{\"name\":\"libc\",\"req\":\"^0.2.178\",\"target\":\"cfg(target_os = \\\"wasi\\\")\"},{\"name\":\"libc\",\"req\":\"^0.2.178\",\"target\":\"cfg(unix)\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"name\":\"wasi\",\"req\":\"^0.11.0\",\"target\":\"cfg(target_os = \\\"wasi\\\")\"},{\"features\":[\"Wdk_Foundation\",\"Wdk_Storage_FileSystem\",\"Wdk_System_IO\",\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_Storage_FileSystem\",\"Win32_Security\",\"Win32_System_IO\",\"Win32_System_WindowsProgramming\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"log\"],\"net\":[],\"os-ext\":[\"os-poll\",\"windows-sys/Win32_System_Pipes\",\"windows-sys/Win32_Security\"],\"os-poll\":[]}}", + "miow_0.6.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.0\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.6.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_Security\",\"Win32_Storage_FileSystem\",\"Win32_System_IO\",\"Win32_System_Pipes\",\"Win32_System_Threading\"],\"name\":\"windows-sys\",\"req\":\">=0.60, <=0.61\"}],\"features\":{}}", "moka_0.12.13": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-rt\",\"req\":\"^2.8\"},{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.8.3\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.19\"},{\"name\":\"async-lock\",\"optional\":true,\"req\":\"^3.3\"},{\"name\":\"crossbeam-channel\",\"req\":\"^0.5.15\"},{\"name\":\"crossbeam-epoch\",\"req\":\"^0.9.18\"},{\"name\":\"crossbeam-utils\",\"req\":\"^0.8.21\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.0\"},{\"name\":\"equivalent\",\"req\":\"^1.0\"},{\"name\":\"event-listener\",\"optional\":true,\"req\":\"^5.3\"},{\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.17\"},{\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(moka_loom)\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.7\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"name\":\"portable-atomic\",\"req\":\"^1.6\"},{\"name\":\"quanta\",\"optional\":true,\"req\":\"^0.12.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"default_features\":false,\"features\":[\"rustls-tls\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.12\"},{\"name\":\"smallvec\",\"req\":\"^1.8\"},{\"name\":\"tagptr\",\"req\":\"^0.2\"},{\"features\":[\"fs\",\"io-util\",\"macros\",\"rt-multi-thread\",\"sync\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.19\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\",\"target\":\"cfg(trybuild)\"},{\"features\":[\"v4\"],\"name\":\"uuid\",\"req\":\"^1.1\"}],\"features\":{\"atomic64\":[],\"default\":[],\"future\":[\"async-lock\",\"event-listener\",\"futures-util\"],\"logging\":[\"log\"],\"quanta\":[\"dep:quanta\"],\"sync\":[],\"unstable-debug-counters\":[\"future\"]}}", "moxcms_0.7.11": "{\"dependencies\":[{\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"name\":\"pxfm\",\"req\":\"^0.1.1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"}],\"features\":{\"avx\":[],\"avx512\":[],\"default\":[\"avx\",\"sse\",\"neon\"],\"neon\":[],\"options\":[],\"sse\":[]}}", "multimap_0.10.1": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"serde_impl\"],\"serde_impl\":[\"serde\"]}}", @@ -1072,6 +1100,7 @@ "oboe_0.6.1": "{\"dependencies\":[{\"name\":\"jni\",\"optional\":true,\"req\":\"^0.21\"},{\"default_features\":false,\"name\":\"ndk\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"ndk-context\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"num-derive\",\"req\":\"^0.4\"},{\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"name\":\"oboe-sys\",\"req\":\"^0.6\"}],\"features\":{\"doc-cfg\":[],\"fetch-prebuilt\":[\"oboe-sys/fetch-prebuilt\"],\"generate-bindings\":[\"oboe-sys/generate-bindings\"],\"java-interface\":[\"ndk\",\"ndk-context\",\"jni\"],\"shared-link\":[\"oboe-sys/shared-link\"],\"shared-stdcxx\":[\"oboe-sys/shared-stdcxx\"]}}", "oid-registry_0.8.1": "{\"dependencies\":[{\"name\":\"asn1-rs\",\"req\":\"^0.7\"}],\"features\":{\"crypto\":[\"kdf\",\"pkcs1\",\"pkcs7\",\"pkcs9\",\"pkcs12\",\"nist_algs\",\"x962\"],\"default\":[\"registry\"],\"kdf\":[],\"ms_spc\":[],\"nist_algs\":[],\"pkcs1\":[],\"pkcs12\":[],\"pkcs7\":[],\"pkcs9\":[],\"registry\":[],\"x500\":[],\"x509\":[],\"x962\":[]}}", "once_cell_1.21.3": "{\"dependencies\":[{\"name\":\"critical-section\",\"optional\":true,\"req\":\"^1.1.3\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"critical-section\",\"req\":\"^1.1.3\"},{\"default_features\":false,\"name\":\"parking_lot_core\",\"optional\":true,\"req\":\"^0.9.10\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.10.6\"}],\"features\":{\"alloc\":[\"race\"],\"atomic-polyfill\":[\"critical-section\"],\"critical-section\":[\"dep:critical-section\",\"portable-atomic\"],\"default\":[\"std\"],\"parking_lot\":[\"dep:parking_lot_core\"],\"portable-atomic\":[\"dep:portable-atomic\"],\"race\":[],\"std\":[\"alloc\"],\"unstable\":[]}}", + "once_cell_1.21.4": "{\"dependencies\":[{\"name\":\"critical-section\",\"optional\":true,\"req\":\"^1.1.3\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"critical-section\",\"req\":\"^1.1.3\"},{\"default_features\":false,\"name\":\"parking_lot_core\",\"optional\":true,\"req\":\"^0.9.10\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.10.6\"}],\"features\":{\"alloc\":[\"race\"],\"atomic-polyfill\":[\"critical-section\"],\"critical-section\":[\"dep:critical-section\",\"portable-atomic\"],\"default\":[\"std\"],\"parking_lot\":[\"dep:parking_lot_core\"],\"portable-atomic\":[\"dep:portable-atomic\"],\"race\":[],\"std\":[\"alloc\"],\"unstable\":[]}}", "once_cell_polyfill_1.70.2": "{\"dependencies\":[],\"features\":{\"default\":[]}}", "onig_6.5.1": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(windows)\"},{\"name\":\"once_cell\",\"req\":\"^1.12\"},{\"default_features\":false,\"name\":\"onig_sys\",\"req\":\"^69.9.1\"}],\"features\":{\"default\":[\"generate\"],\"generate\":[\"onig_sys/generate\"],\"posix-api\":[\"onig_sys/posix-api\"],\"print-debug\":[\"onig_sys/print-debug\"],\"std-pattern\":[]}}", "onig_sys_69.9.1": "{\"dependencies\":[{\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.71\"},{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.16\"}],\"features\":{\"default\":[\"generate\"],\"generate\":[\"bindgen\"],\"posix-api\":[],\"print-debug\":[]}}", @@ -1081,6 +1110,7 @@ "openssl-probe_0.2.1": "{\"dependencies\":[],\"features\":{}}", "openssl-src_300.5.5+3.5.5": "{\"dependencies\":[{\"name\":\"cc\",\"req\":\"^1.0.79\"}],\"features\":{\"camellia\":[],\"default\":[],\"force-engine\":[],\"idea\":[],\"ktls\":[],\"legacy\":[],\"no-dso\":[],\"seed\":[],\"ssl3\":[],\"weak-crypto\":[]}}", "openssl-sys_0.9.111": "{\"dependencies\":[{\"features\":[\"ssl\",\"bindgen\"],\"name\":\"aws-lc-fips-sys\",\"optional\":true,\"req\":\"^0.13\"},{\"features\":[\"ssl\"],\"name\":\"aws-lc-sys\",\"optional\":true,\"req\":\"^0.27\"},{\"features\":[\"experimental\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72.0\"},{\"name\":\"bssl-sys\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.61\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"features\":[\"legacy\"],\"kind\":\"build\",\"name\":\"openssl-src\",\"optional\":true,\"req\":\"^300.2.0\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.8\"}],\"features\":{\"aws-lc\":[\"dep:aws-lc-sys\"],\"aws-lc-fips\":[\"dep:aws-lc-fips-sys\"],\"unstable_boringssl\":[\"bssl-sys\"],\"vendored\":[\"openssl-src\"]}}", + "openssl-sys_0.9.112": "{\"dependencies\":[{\"features\":[\"ssl\",\"bindgen\"],\"name\":\"aws-lc-fips-sys\",\"optional\":true,\"req\":\"^0.13\"},{\"features\":[\"ssl\"],\"name\":\"aws-lc-sys\",\"optional\":true,\"req\":\"^0.38\"},{\"features\":[\"experimental\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72.0\"},{\"name\":\"bssl-sys\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.61\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"features\":[\"legacy\"],\"kind\":\"build\",\"name\":\"openssl-src\",\"optional\":true,\"req\":\"^300.2.0\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.8\"}],\"features\":{\"aws-lc\":[\"dep:aws-lc-sys\"],\"aws-lc-fips\":[\"dep:aws-lc-fips-sys\"],\"unstable_boringssl\":[\"bssl-sys\"],\"vendored\":[\"openssl-src\"]}}", "openssl_0.10.75": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.2.1\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"ffi\",\"package\":\"openssl-sys\",\"req\":\"^0.9.111\"},{\"name\":\"foreign-types\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"once_cell\",\"req\":\"^1.5.2\"},{\"name\":\"openssl-macros\",\"req\":\"^0.1.1\"}],\"features\":{\"aws-lc\":[\"ffi/aws-lc\"],\"aws-lc-fips\":[\"ffi/aws-lc-fips\"],\"bindgen\":[\"ffi/bindgen\"],\"default\":[],\"unstable_boringssl\":[\"ffi/unstable_boringssl\"],\"v101\":[],\"v102\":[],\"v110\":[],\"v111\":[],\"vendored\":[\"ffi/vendored\"]}}", "opentelemetry-appender-tracing_0.31.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.21\"},{\"default_features\":false,\"features\":[\"logs\"],\"name\":\"opentelemetry\",\"req\":\"^0.31\"},{\"default_features\":false,\"features\":[\"logs\"],\"kind\":\"dev\",\"name\":\"opentelemetry-stdout\",\"req\":\"^0.31\"},{\"default_features\":false,\"features\":[\"logs\",\"testing\",\"internal-logs\"],\"kind\":\"dev\",\"name\":\"opentelemetry_sdk\",\"req\":\"^0.31\"},{\"features\":[\"flamegraph\",\"criterion\"],\"kind\":\"dev\",\"name\":\"pprof\",\"req\":\"^0.14\",\"target\":\"cfg(not(target_os = \\\"windows\\\"))\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\">=0.1.40\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\">=0.1.40\"},{\"default_features\":false,\"name\":\"tracing-core\",\"req\":\">=0.1.33\"},{\"name\":\"tracing-log\",\"optional\":true,\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"tracing-log\",\"req\":\"^0.2\"},{\"name\":\"tracing-opentelemetry\",\"optional\":true,\"req\":\"^0.32\"},{\"default_features\":false,\"features\":[\"registry\",\"std\"],\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"env-filter\",\"registry\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"}],\"features\":{\"default\":[],\"experimental_metadata_attributes\":[\"dep:tracing-log\"],\"experimental_use_tracing_span_context\":[\"tracing-opentelemetry\"],\"spec_unstable_logs_enabled\":[\"opentelemetry/spec_unstable_logs_enabled\"]}}", "opentelemetry-http_0.31.0": "{\"dependencies\":[{\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"http\",\"req\":\"^1.1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"hyper\",\"optional\":true,\"req\":\"^1.3\"},{\"features\":[\"client-legacy\",\"http1\",\"http2\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"trace\"],\"name\":\"opentelemetry\",\"req\":\"^0.31\"},{\"default_features\":false,\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.12\"},{\"default_features\":false,\"features\":[\"time\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"default\":[\"internal-logs\"],\"hyper\":[\"dep:http-body-util\",\"dep:hyper\",\"dep:hyper-util\",\"dep:tokio\"],\"internal-logs\":[\"opentelemetry/internal-logs\"],\"reqwest\":[\"dep:reqwest\"],\"reqwest-blocking\":[\"dep:reqwest\",\"reqwest/blocking\"],\"reqwest-rustls\":[\"dep:reqwest\",\"reqwest/rustls-tls-native-roots\"],\"reqwest-rustls-webpki-roots\":[\"dep:reqwest\",\"reqwest/rustls-tls-webpki-roots\"]}}", @@ -1111,12 +1141,14 @@ "phf_shared_0.11.3": "{\"dependencies\":[{\"name\":\"siphasher\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"uncased\",\"optional\":true,\"req\":\"^0.9.9\"},{\"name\":\"unicase\",\"optional\":true,\"req\":\"^2.4.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "pin-project-internal_1.1.10": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1.0.25\"},{\"default_features\":false,\"features\":[\"parsing\",\"printing\",\"clone-impls\",\"proc-macro\",\"full\",\"visit-mut\"],\"name\":\"syn\",\"req\":\"^2.0.1\"}],\"features\":{}}", "pin-project-lite_0.2.16": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"}],\"features\":{}}", + "pin-project-lite_0.2.17": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"}],\"features\":{}}", "pin-project_1.1.10": "{\"dependencies\":[{\"name\":\"pin-project-internal\",\"req\":\"=1.1.10\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"}],\"features\":{}}", "pin-utils_0.1.0": "{\"dependencies\":[],\"features\":{}}", "piper_0.2.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-channel\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"async-executor\",\"req\":\"^1.5.1\"},{\"kind\":\"dev\",\"name\":\"async-io\",\"req\":\"^2.0.0\"},{\"name\":\"atomic-waker\",\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3.2.0\"},{\"default_features\":false,\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.28\"},{\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2.0.0\"},{\"features\":[\"alloc\"],\"name\":\"portable-atomic-util\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"portable_atomic_crate\",\"optional\":true,\"package\":\"portable-atomic\",\"req\":\"^1.2.0\"}],\"features\":{\"default\":[\"std\"],\"portable-atomic\":[\"atomic-waker/portable-atomic\",\"portable_atomic_crate\",\"portable-atomic-util\"],\"std\":[\"fastrand/std\",\"futures-io\"]}}", "pkcs1_0.7.5": "{\"dependencies\":[{\"features\":[\"db\"],\"kind\":\"dev\",\"name\":\"const-oid\",\"req\":\"^0.9\"},{\"features\":[\"oid\"],\"name\":\"der\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"pkcs8\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"spki\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"alloc\":[\"der/alloc\",\"zeroize\",\"pkcs8?/alloc\"],\"pem\":[\"alloc\",\"der/pem\",\"pkcs8?/pem\"],\"std\":[\"der/std\",\"alloc\"],\"zeroize\":[\"der/zeroize\"]}}", "pkcs8_0.10.2": "{\"dependencies\":[{\"features\":[\"oid\"],\"name\":\"der\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"name\":\"pkcs5\",\"optional\":true,\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"spki\",\"req\":\"^0.7.1\"},{\"default_features\":false,\"name\":\"subtle\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"3des\":[\"encryption\",\"pkcs5/3des\"],\"alloc\":[\"der/alloc\",\"der/zeroize\",\"spki/alloc\"],\"des-insecure\":[\"encryption\",\"pkcs5/des-insecure\"],\"encryption\":[\"alloc\",\"pkcs5/alloc\",\"pkcs5/pbes2\",\"rand_core\"],\"getrandom\":[\"rand_core/getrandom\"],\"pem\":[\"alloc\",\"der/pem\",\"spki/pem\"],\"sha1-insecure\":[\"encryption\",\"pkcs5/sha1-insecure\"],\"std\":[\"alloc\",\"der/std\",\"spki/std\"]}}", "pkg-config_0.3.32": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"}],\"features\":{}}", + "plain_0.2.3": "{\"dependencies\":[],\"features\":{}}", "plist_1.8.0": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22.0\"},{\"name\":\"indexmap\",\"req\":\"^2.1.0\"},{\"name\":\"quick_xml\",\"package\":\"quick-xml\",\"req\":\"^0.38.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"serde_yaml\",\"req\":\"^0.8.21\"},{\"features\":[\"parsing\",\"formatting\"],\"name\":\"time\",\"req\":\"^0.3.30\"}],\"features\":{\"default\":[\"serde\"],\"enable_unstable_features_that_may_break_with_minor_version_bumps\":[]}}", "png_0.18.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"approx\",\"req\":\"^0.5.1\"},{\"name\":\"bitflags\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"byteorder\",\"req\":\"^1.5.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.0\"},{\"name\":\"crc32fast\",\"req\":\"^1.2.0\"},{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"fdeflate\",\"req\":\"^0.3.3\"},{\"name\":\"flate2\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3\"},{\"features\":[\"simd\"],\"name\":\"miniz_oxide\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.2\"}],\"features\":{\"benchmarks\":[],\"unstable\":[\"crc32fast/nightly\"],\"zlib-rs\":[\"flate2/zlib-rs\"]}}", "polling_3.11.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"concurrent-queue\",\"req\":\"^2.2.0\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3.1.0\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"hermit-abi\",\"req\":\"^0.5.0\",\"target\":\"cfg(target_os = \\\"hermit\\\")\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.9\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"features\":[\"event\",\"fs\",\"pipe\",\"process\",\"std\",\"time\"],\"name\":\"rustix\",\"req\":\"^1.0.5\",\"target\":\"cfg(any(unix, target_os = \\\"fuchsia\\\", target_os = \\\"vxworks\\\"))\"},{\"kind\":\"dev\",\"name\":\"signal-hook\",\"req\":\"^0.3.17\",\"target\":\"cfg(all(unix, not(target_os=\\\"vita\\\")))\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.6.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.37\"},{\"features\":[\"Wdk_Foundation\",\"Wdk_Storage_FileSystem\",\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_Security\",\"Win32_Storage_FileSystem\",\"Win32_System_IO\",\"Win32_System_LibraryLoader\",\"Win32_System_Threading\",\"Win32_System_WindowsProgramming\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", @@ -1152,7 +1184,9 @@ "quinn-udp_0.5.14": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"async_tokio\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"name\":\"libc\",\"req\":\"^0.2.158\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"once_cell\",\"req\":\"^1.19\",\"target\":\"cfg(windows)\"},{\"name\":\"socket2\",\"req\":\">=0.5, <0.7\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"features\":[\"sync\",\"rt\",\"rt-multi-thread\",\"net\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.28.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.10\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_IO\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <=0.60\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"tracing\",\"log\"],\"direct-log\":[\"dep:log\"],\"fast-apple-datapath\":[],\"log\":[\"tracing/log\"]}}", "quinn_0.11.9": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.22\"},{\"name\":\"async-io\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"async-std\",\"optional\":true,\"req\":\"^1.11\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4\"},{\"kind\":\"dev\",\"name\":\"crc\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"directories-next\",\"req\":\"^2\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.19\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"proto\",\"package\":\"quinn-proto\",\"req\":\"^0.11.12\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"name\":\"rustc-hash\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.5\"},{\"kind\":\"dev\",\"name\":\"rustls-pemfile\",\"req\":\"^2\"},{\"name\":\"smol\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"socket2\",\"req\":\">=0.5, <0.7\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"thiserror\",\"req\":\"^2.0.3\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.28.1\"},{\"features\":[\"sync\",\"rt\",\"rt-multi-thread\",\"time\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.28.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.10\"},{\"default_features\":false,\"features\":[\"std-future\"],\"kind\":\"dev\",\"name\":\"tracing-futures\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"env-filter\",\"fmt\",\"ansi\",\"time\",\"local-time\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"},{\"default_features\":false,\"features\":[\"tracing\"],\"name\":\"udp\",\"package\":\"quinn-udp\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2\"},{\"name\":\"web-time\",\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"}],\"features\":{\"aws-lc-rs\":[\"proto/aws-lc-rs\"],\"aws-lc-rs-fips\":[\"proto/aws-lc-rs-fips\"],\"bloom\":[\"proto/bloom\"],\"default\":[\"log\",\"platform-verifier\",\"runtime-tokio\",\"rustls-ring\",\"bloom\"],\"lock_tracking\":[],\"log\":[\"tracing/log\",\"proto/log\",\"udp/log\"],\"platform-verifier\":[\"proto/platform-verifier\"],\"qlog\":[\"proto/qlog\"],\"ring\":[\"proto/ring\"],\"runtime-async-std\":[\"async-io\",\"async-std\"],\"runtime-smol\":[\"async-io\",\"smol\"],\"runtime-tokio\":[\"tokio/time\",\"tokio/rt\",\"tokio/net\"],\"rustls\":[\"rustls-ring\"],\"rustls-aws-lc-rs\":[\"dep:rustls\",\"aws-lc-rs\",\"proto/rustls-aws-lc-rs\",\"proto/aws-lc-rs\"],\"rustls-aws-lc-rs-fips\":[\"dep:rustls\",\"aws-lc-rs-fips\",\"proto/rustls-aws-lc-rs-fips\",\"proto/aws-lc-rs-fips\"],\"rustls-log\":[\"rustls?/logging\"],\"rustls-ring\":[\"dep:rustls\",\"ring\",\"proto/rustls-ring\",\"proto/ring\"]}}", "quote_1.0.44": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.80\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"}],\"features\":{\"default\":[\"proc-macro\"],\"proc-macro\":[\"proc-macro2/proc-macro\"]}}", + "quote_1.0.45": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.80\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"}],\"features\":{\"default\":[\"proc-macro\"],\"proc-macro\":[\"proc-macro2/proc-macro\"]}}", "r-efi_5.3.0": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"}],\"features\":{\"efiapi\":[],\"examples\":[\"native\"],\"native\":[],\"rustc-dep-of-std\":[\"core\"]}}", + "r-efi_6.0.0": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"}],\"features\":{\"native\":[],\"rustc-dep-of-std\":[\"core\"]}}", "radix_trie_0.2.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"name\":\"endian-type\",\"req\":\"^0.1.2\"},{\"name\":\"nibble_vec\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.3\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{}}", "radix_trie_0.3.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"name\":\"endian-type\",\"req\":\"^0.2.0\"},{\"name\":\"nibble_vec\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{}}", "rama-core_0.3.0-alpha.4": "{\"dependencies\":[{\"name\":\"ahash\",\"req\":\"^0.8\"},{\"name\":\"asynk-strim\",\"req\":\"^0.1\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"futures\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"trace\"],\"name\":\"opentelemetry\",\"optional\":true,\"req\":\"^0.31\"},{\"features\":[\"semconv_experimental\"],\"name\":\"opentelemetry-semantic-conventions\",\"optional\":true,\"req\":\"^0.31\"},{\"default_features\":false,\"features\":[\"trace\",\"rt-tokio\"],\"name\":\"opentelemetry_sdk\",\"optional\":true,\"req\":\"^0.31\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"name\":\"rama-error\",\"req\":\"^0.3.0-alpha.4\"},{\"name\":\"rama-macros\",\"req\":\"^0.3.0-alpha.4\"},{\"name\":\"rama-utils\",\"req\":\"^0.3.0-alpha.4\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"macros\",\"fs\",\"io-std\"],\"name\":\"tokio\",\"req\":\"^1.48\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.48\"},{\"name\":\"tokio-graceful\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"features\":[\"codec\",\"io\",\"io-util\"],\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"name\":\"tracing-opentelemetry\",\"optional\":true,\"req\":\"^0.32\"}],\"features\":{\"default\":[],\"opentelemetry\":[\"dep:opentelemetry\",\"dep:opentelemetry-semantic-conventions\",\"dep:opentelemetry_sdk\",\"dep:tracing-opentelemetry\"]}}", @@ -1184,13 +1218,16 @@ "rcgen_0.14.7": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.13.3\"},{\"kind\":\"dev\",\"name\":\"openssl\",\"req\":\"^0.10\",\"target\":\"cfg(unix)\"},{\"name\":\"pem\",\"optional\":true,\"req\":\"^3.0.2\"},{\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.4.1\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"default_features\":false,\"name\":\"time\",\"req\":\"^0.3.6\"},{\"name\":\"x509-parser\",\"optional\":true,\"req\":\"^0.18\"},{\"features\":[\"time\",\"std\"],\"name\":\"yasna\",\"req\":\"^0.5.2\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.2\"}],\"features\":{\"aws_lc_rs\":[\"crypto\",\"dep:aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"x509-parser?/verify-aws\"],\"aws_lc_rs_unstable\":[\"aws_lc_rs\",\"aws-lc-rs/unstable\",\"x509-parser?/verify-aws\"],\"crypto\":[],\"default\":[\"crypto\",\"pem\",\"ring\"],\"fips\":[\"crypto\",\"dep:aws-lc-rs\",\"aws-lc-rs/fips\"],\"ring\":[\"crypto\",\"dep:ring\",\"x509-parser?/verify\"]}}", "redox_syscall_0.5.18": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.4\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"}],\"features\":{\"default\":[\"userspace\"],\"rustc-dep-of-std\":[\"core\",\"bitflags/rustc-dep-of-std\"],\"std\":[],\"userspace\":[]}}", "redox_syscall_0.7.0": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.4\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"}],\"features\":{\"default\":[\"userspace\"],\"rustc-dep-of-std\":[\"core\",\"bitflags/rustc-dep-of-std\"],\"std\":[],\"userspace\":[]}}", + "redox_syscall_0.7.3": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.4\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"}],\"features\":{\"default\":[\"userspace\"],\"rustc-dep-of-std\":[\"core\",\"bitflags/rustc-dep-of-std\"],\"std\":[],\"userspace\":[]}}", "redox_users_0.4.6": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"getrandom\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"std\",\"call\"],\"name\":\"libredox\",\"req\":\"^0.1.3\"},{\"name\":\"rust-argon2\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"thiserror\",\"req\":\"^1.0\"},{\"features\":[\"zeroize_derive\"],\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.4\"}],\"features\":{\"auth\":[\"rust-argon2\",\"zeroize\"],\"default\":[\"auth\"]}}", "redox_users_0.5.2": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"getrandom\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"std\",\"call\"],\"name\":\"libredox\",\"req\":\"^0.1.3\"},{\"name\":\"rust-argon2\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"zeroize_derive\"],\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.4\"}],\"features\":{\"auth\":[\"rust-argon2\",\"zeroize\"],\"default\":[\"auth\"]}}", "ref-cast-impl_1.0.25": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1\"},{\"name\":\"syn\",\"req\":\"^2.0.46\"}],\"features\":{}}", "ref-cast_1.0.25": "{\"dependencies\":[{\"name\":\"ref-cast-impl\",\"req\":\"=1.0.25\"},{\"kind\":\"dev\",\"name\":\"ref-cast-test-suite\",\"req\":\"^0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"}],\"features\":{}}", "regex-automata_0.4.13": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aho-corasick\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.69\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"bstr\",\"req\":\"^1.3.0\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.3\"},{\"default_features\":false,\"features\":[\"atty\",\"humantime\",\"termcolor\"],\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.9.3\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.14\"},{\"default_features\":false,\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.6.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"name\":\"regex-syntax\",\"optional\":true,\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"regex-test\",\"req\":\"^0.1.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\",\"syntax\",\"perf\",\"unicode\",\"meta\",\"nfa\",\"dfa\",\"hybrid\"],\"dfa\":[\"dfa-build\",\"dfa-search\",\"dfa-onepass\"],\"dfa-build\":[\"nfa-thompson\",\"dfa-search\"],\"dfa-onepass\":[\"nfa-thompson\"],\"dfa-search\":[],\"hybrid\":[\"alloc\",\"nfa-thompson\"],\"internal-instrument\":[\"internal-instrument-pikevm\"],\"internal-instrument-pikevm\":[\"logging\",\"std\"],\"logging\":[\"dep:log\",\"aho-corasick?/logging\",\"memchr?/logging\"],\"meta\":[\"syntax\",\"nfa-pikevm\"],\"nfa\":[\"nfa-thompson\",\"nfa-pikevm\",\"nfa-backtrack\"],\"nfa-backtrack\":[\"nfa-thompson\"],\"nfa-pikevm\":[\"nfa-thompson\"],\"nfa-thompson\":[\"alloc\"],\"perf\":[\"perf-inline\",\"perf-literal\"],\"perf-inline\":[],\"perf-literal\":[\"perf-literal-substring\",\"perf-literal-multisubstring\"],\"perf-literal-multisubstring\":[\"dep:aho-corasick\"],\"perf-literal-substring\":[\"aho-corasick?/perf-literal\",\"dep:memchr\"],\"std\":[\"regex-syntax?/std\",\"memchr?/std\",\"aho-corasick?/std\",\"alloc\"],\"syntax\":[\"dep:regex-syntax\",\"alloc\"],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\",\"unicode-word-boundary\",\"regex-syntax?/unicode\"],\"unicode-age\":[\"regex-syntax?/unicode-age\"],\"unicode-bool\":[\"regex-syntax?/unicode-bool\"],\"unicode-case\":[\"regex-syntax?/unicode-case\"],\"unicode-gencat\":[\"regex-syntax?/unicode-gencat\"],\"unicode-perl\":[\"regex-syntax?/unicode-perl\"],\"unicode-script\":[\"regex-syntax?/unicode-script\"],\"unicode-segment\":[\"regex-syntax?/unicode-segment\"],\"unicode-word-boundary\":[]}}", + "regex-automata_0.4.14": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aho-corasick\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.69\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"bstr\",\"req\":\"^1.3.0\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.3\"},{\"default_features\":false,\"features\":[\"atty\",\"humantime\",\"termcolor\"],\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.9.3\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.14\"},{\"default_features\":false,\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.6.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"name\":\"regex-syntax\",\"optional\":true,\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"regex-test\",\"req\":\"^0.1.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\",\"syntax\",\"perf\",\"unicode\",\"meta\",\"nfa\",\"dfa\",\"hybrid\"],\"dfa\":[\"dfa-build\",\"dfa-search\",\"dfa-onepass\"],\"dfa-build\":[\"nfa-thompson\",\"dfa-search\"],\"dfa-onepass\":[\"nfa-thompson\"],\"dfa-search\":[],\"hybrid\":[\"alloc\",\"nfa-thompson\"],\"internal-instrument\":[\"internal-instrument-pikevm\"],\"internal-instrument-pikevm\":[\"logging\",\"std\"],\"logging\":[\"dep:log\",\"aho-corasick?/logging\",\"memchr?/logging\"],\"meta\":[\"syntax\",\"nfa-pikevm\"],\"nfa\":[\"nfa-thompson\",\"nfa-pikevm\",\"nfa-backtrack\"],\"nfa-backtrack\":[\"nfa-thompson\"],\"nfa-pikevm\":[\"nfa-thompson\"],\"nfa-thompson\":[\"alloc\"],\"perf\":[\"perf-inline\",\"perf-literal\"],\"perf-inline\":[],\"perf-literal\":[\"perf-literal-substring\",\"perf-literal-multisubstring\"],\"perf-literal-multisubstring\":[\"dep:aho-corasick\"],\"perf-literal-substring\":[\"aho-corasick?/perf-literal\",\"dep:memchr\"],\"std\":[\"regex-syntax?/std\",\"memchr?/std\",\"aho-corasick?/std\",\"alloc\"],\"syntax\":[\"dep:regex-syntax\",\"alloc\"],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\",\"unicode-word-boundary\",\"regex-syntax?/unicode\"],\"unicode-age\":[\"regex-syntax?/unicode-age\"],\"unicode-bool\":[\"regex-syntax?/unicode-bool\"],\"unicode-case\":[\"regex-syntax?/unicode-case\"],\"unicode-gencat\":[\"regex-syntax?/unicode-gencat\"],\"unicode-perl\":[\"regex-syntax?/unicode-perl\"],\"unicode-script\":[\"regex-syntax?/unicode-script\"],\"unicode-segment\":[\"regex-syntax?/unicode-segment\"],\"unicode-word-boundary\":[]}}", "regex-lite_0.1.8": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.69\"},{\"kind\":\"dev\",\"name\":\"regex-test\",\"req\":\"^0.1.0\"}],\"features\":{\"default\":[\"std\",\"string\"],\"std\":[],\"string\":[]}}", "regex-syntax_0.6.29": "{\"dependencies\":[],\"features\":{\"default\":[\"unicode\"],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\"],\"unicode-age\":[],\"unicode-bool\":[],\"unicode-case\":[],\"unicode-gencat\":[],\"unicode-perl\":[],\"unicode-script\":[],\"unicode-segment\":[]}}", + "regex-syntax_0.8.10": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.3.0\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"default\":[\"std\",\"unicode\"],\"std\":[],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\"],\"unicode-age\":[],\"unicode-bool\":[],\"unicode-case\":[],\"unicode-gencat\":[],\"unicode-perl\":[],\"unicode-script\":[],\"unicode-segment\":[]}}", "regex-syntax_0.8.8": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.3.0\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"default\":[\"std\",\"unicode\"],\"std\":[],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\"],\"unicode-age\":[],\"unicode-bool\":[],\"unicode-case\":[],\"unicode-gencat\":[],\"unicode-perl\":[],\"unicode-script\":[],\"unicode-segment\":[]}}", "regex_1.12.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aho-corasick\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.69\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"atty\",\"humantime\",\"termcolor\"],\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.9.3\"},{\"default_features\":false,\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.6.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"features\":[\"alloc\",\"syntax\",\"meta\",\"nfa-pikevm\"],\"name\":\"regex-automata\",\"req\":\"^0.4.12\"},{\"default_features\":false,\"name\":\"regex-syntax\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"regex-test\",\"req\":\"^0.1.0\"}],\"features\":{\"default\":[\"std\",\"perf\",\"unicode\",\"regex-syntax/default\"],\"logging\":[\"aho-corasick?/logging\",\"memchr?/logging\",\"regex-automata/logging\"],\"pattern\":[],\"perf\":[\"perf-cache\",\"perf-dfa\",\"perf-onepass\",\"perf-backtrack\",\"perf-inline\",\"perf-literal\"],\"perf-backtrack\":[\"regex-automata/nfa-backtrack\"],\"perf-cache\":[],\"perf-dfa\":[\"regex-automata/hybrid\"],\"perf-dfa-full\":[\"regex-automata/dfa-build\",\"regex-automata/dfa-search\"],\"perf-inline\":[\"regex-automata/perf-inline\"],\"perf-literal\":[\"dep:aho-corasick\",\"dep:memchr\",\"regex-automata/perf-literal\"],\"perf-onepass\":[\"regex-automata/dfa-onepass\"],\"std\":[\"aho-corasick?/std\",\"memchr?/std\",\"regex-automata/std\",\"regex-syntax/std\"],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\",\"regex-automata/unicode\",\"regex-syntax/unicode\"],\"unicode-age\":[\"regex-automata/unicode-age\",\"regex-syntax/unicode-age\"],\"unicode-bool\":[\"regex-automata/unicode-bool\",\"regex-syntax/unicode-bool\"],\"unicode-case\":[\"regex-automata/unicode-case\",\"regex-syntax/unicode-case\"],\"unicode-gencat\":[\"regex-automata/unicode-gencat\",\"regex-syntax/unicode-gencat\"],\"unicode-perl\":[\"regex-automata/unicode-perl\",\"regex-automata/unicode-word-boundary\",\"regex-syntax/unicode-perl\"],\"unicode-script\":[\"regex-automata/unicode-script\",\"regex-syntax/unicode-script\"],\"unicode-segment\":[\"regex-automata/unicode-segment\",\"regex-syntax/unicode-segment\"],\"unstable\":[\"pattern\"],\"use_std\":[\"std\"]}}", "reqwest_0.12.28": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"brotli_crate\",\"package\":\"brotli\",\"req\":\"^8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"bytes\",\"req\":\"^1.2\"},{\"name\":\"cookie_crate\",\"optional\":true,\"package\":\"cookie\",\"req\":\"^0.18.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"cookie_store\",\"optional\":true,\"req\":\"^0.22.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"encoding_rs\",\"optional\":true,\"req\":\"^0.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0.13\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"std\",\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.28\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h3\",\"optional\":true,\"req\":\"^0.0.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h3-quinn\",\"optional\":true,\"req\":\"^0.0.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"tokio\"],\"name\":\"hickory-resolver\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"http\",\"req\":\"^1.1\"},{\"name\":\"http-body\",\"req\":\"^1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.2\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"client\"],\"name\":\"hyper\",\"req\":\"^1.1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"http1\",\"http2\",\"client\",\"server\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"http1\",\"tls12\"],\"name\":\"hyper-rustls\",\"optional\":true,\"req\":\"^0.27.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"hyper-tls\",\"optional\":true,\"req\":\"^0.6\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"client\",\"client-legacy\",\"client-proxy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"http2\",\"client\",\"client-legacy\",\"server-auto\",\"server-graceful\",\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"js-sys\",\"req\":\"^0.3.77\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0\"},{\"name\":\"log\",\"req\":\"^0.4.17\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"mime\",\"optional\":true,\"req\":\"^0.3.16\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.0\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.18\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"rustls\",\"runtime-tokio\"],\"name\":\"quinn\",\"optional\":true,\"req\":\"^0.11.1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"std\"],\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.9.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"serde_json\",\"req\":\"^1.0\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_urlencoded\",\"req\":\"^0.7.1\"},{\"features\":[\"futures\"],\"name\":\"sync_wrapper\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"net\",\"time\"],\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tokio-native-tls\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"tls12\"],\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"io\"],\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.9\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"retry\",\"timeout\",\"util\"],\"name\":\"tower\",\"req\":\"^0.5.2\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"limit\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5.2\"},{\"default_features\":false,\"features\":[\"follow-redirect\"],\"name\":\"tower-http\",\"req\":\"^0.6.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tower-service\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"url\",\"req\":\"^2.4\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"serde-serialize\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.18\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-streams\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"AbortController\",\"AbortSignal\",\"Headers\",\"Request\",\"RequestInit\",\"RequestMode\",\"Response\",\"Window\",\"FormData\",\"Blob\",\"BlobPropertyBag\",\"ServiceWorkerGlobalScope\",\"RequestCredentials\",\"File\",\"ReadableStream\",\"RequestCache\"],\"name\":\"web-sys\",\"req\":\"^0.3.28\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"zstd_crate\",\"package\":\"zstd\",\"req\":\"^0.13\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"}],\"features\":{\"__rustls\":[\"dep:hyper-rustls\",\"dep:tokio-rustls\",\"dep:rustls\",\"__tls\"],\"__rustls-ring\":[\"hyper-rustls?/ring\",\"tokio-rustls?/ring\",\"rustls?/ring\",\"quinn?/ring\"],\"__tls\":[\"dep:rustls-pki-types\",\"tokio/io-util\"],\"blocking\":[\"dep:futures-channel\",\"futures-channel?/sink\",\"dep:futures-util\",\"futures-util?/io\",\"futures-util?/sink\",\"tokio/sync\"],\"brotli\":[\"tower-http/decompression-br\"],\"charset\":[\"dep:encoding_rs\",\"dep:mime\"],\"cookies\":[\"dep:cookie_crate\",\"dep:cookie_store\"],\"default\":[\"default-tls\",\"charset\",\"http2\",\"system-proxy\"],\"default-tls\":[\"dep:hyper-tls\",\"dep:native-tls-crate\",\"__tls\",\"dep:tokio-native-tls\"],\"deflate\":[\"tower-http/decompression-deflate\"],\"gzip\":[\"tower-http/decompression-gzip\"],\"hickory-dns\":[\"dep:hickory-resolver\",\"dep:once_cell\"],\"http2\":[\"h2\",\"hyper/http2\",\"hyper-util/http2\",\"hyper-rustls?/http2\"],\"http3\":[\"rustls-tls-manual-roots\",\"dep:h3\",\"dep:h3-quinn\",\"dep:quinn\",\"tokio/macros\"],\"json\":[\"dep:serde_json\"],\"macos-system-configuration\":[\"system-proxy\"],\"multipart\":[\"dep:mime_guess\",\"dep:futures-util\"],\"native-tls\":[\"default-tls\"],\"native-tls-alpn\":[\"native-tls\",\"native-tls-crate?/alpn\",\"hyper-tls?/alpn\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate?/vendored\"],\"rustls-tls\":[\"rustls-tls-webpki-roots\"],\"rustls-tls-manual-roots\":[\"rustls-tls-manual-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-manual-roots-no-provider\":[\"__rustls\"],\"rustls-tls-native-roots\":[\"rustls-tls-native-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-native-roots-no-provider\":[\"dep:rustls-native-certs\",\"hyper-rustls?/native-tokio\",\"__rustls\"],\"rustls-tls-no-provider\":[\"rustls-tls-manual-roots-no-provider\"],\"rustls-tls-webpki-roots\":[\"rustls-tls-webpki-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-webpki-roots-no-provider\":[\"dep:webpki-roots\",\"hyper-rustls?/webpki-tokio\",\"__rustls\"],\"socks\":[],\"stream\":[\"tokio/fs\",\"dep:futures-util\",\"dep:tokio-util\",\"dep:wasm-streams\"],\"system-proxy\":[\"hyper-util/client-proxy-system\"],\"trust-dns\":[],\"zstd\":[\"tower-http/decompression-zstd\"]}}", @@ -1207,10 +1244,13 @@ "rustc-demangle_0.1.27": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"}],\"features\":{\"compiler_builtins\":[],\"rustc-dep-of-std\":[\"core\"],\"std\":[]}}", "rustc-hash_1.1.0": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "rustc-hash_2.1.1": "{\"dependencies\":[{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.8\"}],\"features\":{\"default\":[\"std\"],\"nightly\":[],\"rand\":[\"dep:rand\",\"std\"],\"std\":[]}}", + "rustc_apfloat_0.2.3+llvm-462a31f5a5ab": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.6.0\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"features\":[\"const_generics\",\"union\"],\"name\":\"smallvec\",\"req\":\"^1.11.0\"}],\"features\":{}}", "rustc_version_0.4.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"semver\",\"req\":\"^1.0\"}],\"features\":{}}", + "rustfix_0.8.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.5.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.204\"},{\"name\":\"serde_json\",\"req\":\"^1.0.120\"},{\"kind\":\"dev\",\"name\":\"similar\",\"req\":\"^2.6.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"},{\"name\":\"thiserror\",\"req\":\"^1.0.63\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.40\"},{\"features\":[\"env-filter\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.18\"}],\"features\":{}}", "rusticata-macros_4.1.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"nom\",\"req\":\"^7.0\"}],\"features\":{}}", "rustix_0.38.44": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.49\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(criterion, not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"itoa\",\"optional\":true,\"req\":\"^1.0.13\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.161\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.161\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.161\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"name\":\"libc_errno\",\"optional\":true,\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\"},{\"default_features\":false,\"features\":[\"general\",\"ioctl\",\"no_std\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.4.14\",\"target\":\"cfg(all(any(target_os = \\\"android\\\", target_os = \\\"linux\\\"), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"features\":[\"general\",\"errno\",\"ioctl\",\"no_std\",\"elf\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.4.14\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.5.2\",\"target\":\"cfg(any(target_os = \\\"android\\\", target_os = \\\"linux\\\"))\"},{\"name\":\"rustc-std-workspace-alloc\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_NetworkManagement_IpHelper\",\"Win32_System_Threading\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <=0.59\",\"target\":\"cfg(windows)\"}],\"features\":{\"all-apis\":[\"event\",\"fs\",\"io_uring\",\"mm\",\"mount\",\"net\",\"param\",\"pipe\",\"process\",\"procfs\",\"pty\",\"rand\",\"runtime\",\"shm\",\"stdio\",\"system\",\"termios\",\"thread\",\"time\"],\"alloc\":[],\"cc\":[],\"default\":[\"std\",\"use-libc-auxv\"],\"event\":[],\"fs\":[],\"io_uring\":[\"event\",\"fs\",\"net\",\"linux-raw-sys/io_uring\"],\"libc-extra-traits\":[\"libc?/extra_traits\"],\"linux_4_11\":[],\"linux_latest\":[\"linux_4_11\"],\"mm\":[],\"mount\":[],\"net\":[\"linux-raw-sys/net\",\"linux-raw-sys/netlink\",\"linux-raw-sys/if_ether\",\"linux-raw-sys/xdp\"],\"param\":[\"fs\"],\"pipe\":[],\"process\":[\"linux-raw-sys/prctl\"],\"procfs\":[\"once_cell\",\"itoa\",\"fs\"],\"pty\":[\"itoa\",\"fs\"],\"rand\":[],\"runtime\":[\"linux-raw-sys/prctl\"],\"rustc-dep-of-std\":[\"core\",\"rustc-std-workspace-alloc\",\"compiler_builtins\",\"linux-raw-sys/rustc-dep-of-std\",\"bitflags/rustc-dep-of-std\",\"compiler_builtins?/rustc-dep-of-std\"],\"shm\":[\"fs\"],\"std\":[\"bitflags/std\",\"alloc\",\"libc?/std\",\"libc_errno?/std\",\"libc-extra-traits\"],\"stdio\":[],\"system\":[\"linux-raw-sys/system\"],\"termios\":[],\"thread\":[\"linux-raw-sys/prctl\"],\"time\":[],\"try_close\":[],\"use-explicitly-provided-auxv\":[],\"use-libc\":[\"libc_errno\",\"libc\",\"libc-extra-traits\"],\"use-libc-auxv\":[]}}", "rustix_1.1.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(criterion, not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.177\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.177\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.171\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"name\":\"libc_errno\",\"optional\":true,\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\"},{\"default_features\":false,\"features\":[\"general\",\"ioctl\",\"no_std\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.11.0\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"features\":[\"auxvec\",\"general\",\"errno\",\"ioctl\",\"no_std\",\"elf\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.11.0\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.20.3\",\"target\":\"cfg(windows)\"},{\"name\":\"rustc-std-workspace-alloc\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"all-apis\":[\"event\",\"fs\",\"io_uring\",\"mm\",\"mount\",\"net\",\"param\",\"pipe\",\"process\",\"pty\",\"rand\",\"runtime\",\"shm\",\"stdio\",\"system\",\"termios\",\"thread\",\"time\"],\"alloc\":[],\"default\":[\"std\"],\"event\":[],\"fs\":[],\"io_uring\":[\"event\",\"fs\",\"net\",\"thread\",\"linux-raw-sys/io_uring\"],\"linux_4_11\":[],\"linux_5_1\":[\"linux_4_11\"],\"linux_5_11\":[\"linux_5_1\"],\"linux_latest\":[\"linux_5_11\"],\"mm\":[],\"mount\":[],\"net\":[\"linux-raw-sys/net\",\"linux-raw-sys/netlink\",\"linux-raw-sys/if_ether\",\"linux-raw-sys/xdp\"],\"param\":[],\"pipe\":[],\"process\":[\"linux-raw-sys/prctl\"],\"pty\":[\"fs\"],\"rand\":[],\"runtime\":[\"linux-raw-sys/prctl\"],\"rustc-dep-of-std\":[\"core\",\"rustc-std-workspace-alloc\",\"linux-raw-sys/rustc-dep-of-std\",\"bitflags/rustc-dep-of-std\"],\"shm\":[\"fs\"],\"std\":[\"bitflags/std\",\"alloc\",\"libc?/std\",\"libc_errno?/std\"],\"stdio\":[],\"system\":[\"linux-raw-sys/system\"],\"termios\":[],\"thread\":[\"linux-raw-sys/prctl\"],\"time\":[],\"try_close\":[],\"use-explicitly-provided-auxv\":[],\"use-libc\":[\"libc_errno\",\"libc\"],\"use-libc-auxv\":[]}}", + "rustix_1.1.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(criterion, not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.182\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.182\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.171\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"name\":\"libc_errno\",\"optional\":true,\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\"},{\"default_features\":false,\"features\":[\"general\",\"ioctl\",\"no_std\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.12\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"features\":[\"auxvec\",\"general\",\"errno\",\"ioctl\",\"no_std\",\"elf\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.12\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.20.3\",\"target\":\"cfg(windows)\"},{\"name\":\"rustc-std-workspace-alloc\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"all-apis\":[\"event\",\"fs\",\"io_uring\",\"mm\",\"mount\",\"net\",\"param\",\"pipe\",\"process\",\"pty\",\"rand\",\"runtime\",\"shm\",\"stdio\",\"system\",\"termios\",\"thread\",\"time\"],\"alloc\":[],\"default\":[\"std\"],\"event\":[],\"fs\":[],\"io_uring\":[\"event\",\"fs\",\"net\",\"thread\",\"linux-raw-sys/io_uring\"],\"linux_4_11\":[],\"linux_5_1\":[\"linux_4_11\"],\"linux_5_11\":[\"linux_5_1\"],\"linux_latest\":[\"linux_5_11\"],\"mm\":[],\"mount\":[],\"net\":[\"linux-raw-sys/net\",\"linux-raw-sys/netlink\",\"linux-raw-sys/if_ether\",\"linux-raw-sys/xdp\"],\"param\":[],\"pipe\":[],\"process\":[\"linux-raw-sys/prctl\"],\"pty\":[\"fs\"],\"rand\":[],\"runtime\":[\"linux-raw-sys/prctl\"],\"rustc-dep-of-std\":[\"core\",\"rustc-std-workspace-alloc\",\"linux-raw-sys/rustc-dep-of-std\",\"bitflags/rustc-dep-of-std\"],\"shm\":[\"fs\"],\"std\":[\"bitflags/std\",\"alloc\",\"libc?/std\",\"libc_errno?/std\"],\"stdio\":[],\"system\":[\"linux-raw-sys/system\"],\"termios\":[],\"thread\":[\"linux-raw-sys/prctl\"],\"time\":[],\"try_close\":[],\"use-explicitly-provided-auxv\":[],\"use-libc\":[\"libc_errno\",\"libc\"],\"use-libc-auxv\":[]}}", "rustls-native-certs_0.8.3": "{\"dependencies\":[{\"name\":\"openssl-probe\",\"req\":\"^0.2\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"features\":[\"std\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.10\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"kind\":\"dev\",\"name\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"name\":\"schannel\",\"req\":\"^0.1\",\"target\":\"cfg(windows)\"},{\"name\":\"security-framework\",\"req\":\"^3\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5\"},{\"kind\":\"dev\",\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18\"}],\"features\":{}}", "rustls-pki-types_1.14.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"crabgrind\",\"req\":\"=0.1.9\",\"target\":\"cfg(all(target_os = \\\"linux\\\", target_arch = \\\"x86_64\\\"))\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"dep:zeroize\"],\"default\":[\"alloc\"],\"std\":[\"alloc\"],\"web\":[\"web-time\"]}}", "rustls-webpki_0.103.10": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"kind\":\"dev\",\"name\":\"bzip2\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.17.2\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14.2\"},{\"default_features\":false,\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18.1\"}],\"features\":{\"alloc\":[\"ring?/alloc\",\"pki-types/alloc\"],\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"aws-lc-rs-fips\":[\"dep:aws-lc-rs\",\"aws-lc-rs/fips\"],\"aws-lc-rs-unstable\":[\"aws-lc-rs\",\"aws-lc-rs/unstable\"],\"default\":[\"std\"],\"ring\":[\"dep:ring\"],\"std\":[\"alloc\",\"pki-types/std\"]}}", @@ -1323,15 +1363,17 @@ "supports-color_3.0.2": "{\"dependencies\":[{\"name\":\"is_ci\",\"req\":\"^1.2.0\"}],\"features\":{}}", "syn_1.0.109": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.46\"},{\"default_features\":false,\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.0\"},{\"features\":[\"blocking\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"syn-test-suite\",\"req\":\"^0\"},{\"kind\":\"dev\",\"name\":\"tar\",\"req\":\"^0.4.16\"},{\"kind\":\"dev\",\"name\":\"termcolor\",\"req\":\"^1.0\"},{\"name\":\"unicode-ident\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.1\"}],\"features\":{\"clone-impls\":[],\"default\":[\"derive\",\"parsing\",\"printing\",\"clone-impls\",\"proc-macro\"],\"derive\":[],\"extra-traits\":[],\"fold\":[],\"full\":[],\"parsing\":[],\"printing\":[\"quote\"],\"proc-macro\":[\"proc-macro2/proc-macro\",\"quote/proc-macro\"],\"test\":[\"syn-test-suite/all-features\"],\"visit\":[],\"visit-mut\":[]}}", "syn_2.0.114": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.91\"},{\"default_features\":false,\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1\"},{\"features\":[\"blocking\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.13\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"syn-test-suite\",\"req\":\"^0\"},{\"kind\":\"dev\",\"name\":\"tar\",\"req\":\"^0.4.16\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"termcolor\",\"req\":\"^1\"},{\"name\":\"unicode-ident\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\",\"target\":\"cfg(not(miri))\"}],\"features\":{\"clone-impls\":[],\"default\":[\"derive\",\"parsing\",\"printing\",\"clone-impls\",\"proc-macro\"],\"derive\":[],\"extra-traits\":[],\"fold\":[],\"full\":[],\"parsing\":[],\"printing\":[\"dep:quote\"],\"proc-macro\":[\"proc-macro2/proc-macro\",\"quote?/proc-macro\"],\"test\":[\"syn-test-suite/all-features\"],\"visit\":[],\"visit-mut\":[]}}", + "syn_2.0.117": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.91\"},{\"default_features\":false,\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1\"},{\"features\":[\"blocking\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.13\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"syn-test-suite\",\"req\":\"^0\"},{\"kind\":\"dev\",\"name\":\"tar\",\"req\":\"^0.4.16\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"termcolor\",\"req\":\"^1\"},{\"name\":\"unicode-ident\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\",\"target\":\"cfg(not(miri))\"}],\"features\":{\"clone-impls\":[],\"default\":[\"derive\",\"parsing\",\"printing\",\"clone-impls\",\"proc-macro\"],\"derive\":[],\"extra-traits\":[],\"fold\":[],\"full\":[],\"parsing\":[],\"printing\":[\"dep:quote\"],\"proc-macro\":[\"proc-macro2/proc-macro\",\"quote?/proc-macro\"],\"test\":[\"syn-test-suite/all-features\"],\"visit\":[],\"visit-mut\":[]}}", "sync_wrapper_1.0.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"pin-project-lite\",\"req\":\"^0.2.7\"}],\"features\":{\"futures\":[\"futures-core\"]}}", "synstructure_0.13.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"derive\",\"parsing\",\"printing\",\"clone-impls\",\"visit\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"synstructure_test_traits\",\"req\":\"^0.1\"}],\"features\":{\"default\":[\"proc-macro\"],\"proc-macro\":[\"proc-macro2/proc-macro\",\"syn/proc-macro\",\"quote/proc-macro\"]}}", "syntect_5.3.0": "{\"dependencies\":[{\"name\":\"bincode\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"name\":\"fancy-regex\",\"optional\":true,\"req\":\"^0.16.2\"},{\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"fnv\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"getopts\",\"req\":\"^0.2\"},{\"name\":\"once_cell\",\"req\":\"^1.8\"},{\"default_features\":false,\"name\":\"onig\",\"optional\":true,\"req\":\"^6.5.1\"},{\"name\":\"plist\",\"optional\":true,\"req\":\"^1.3\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"public-api\",\"req\":\"^0.50.1\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.0\"},{\"name\":\"regex-syntax\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rustdoc-json\",\"req\":\"^0.9.7\"},{\"kind\":\"dev\",\"name\":\"rustup-toolchain\",\"req\":\"^0.1.5\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"thiserror\",\"req\":\"^2.0.12\"},{\"name\":\"walkdir\",\"req\":\"^2.0\"},{\"name\":\"yaml-rust\",\"optional\":true,\"req\":\"^0.4.5\"}],\"features\":{\"default\":[\"default-onig\"],\"default-fancy\":[\"parsing\",\"default-syntaxes\",\"default-themes\",\"html\",\"plist-load\",\"yaml-load\",\"dump-load\",\"dump-create\",\"regex-fancy\"],\"default-onig\":[\"parsing\",\"default-syntaxes\",\"default-themes\",\"html\",\"plist-load\",\"yaml-load\",\"dump-load\",\"dump-create\",\"regex-onig\"],\"default-syntaxes\":[\"parsing\",\"dump-load\"],\"default-themes\":[\"dump-load\"],\"dump-create\":[\"flate2\",\"bincode\"],\"dump-load\":[\"flate2\",\"bincode\"],\"html\":[\"parsing\"],\"metadata\":[\"parsing\",\"plist-load\",\"dep:serde_json\"],\"parsing\":[\"regex-syntax\",\"fnv\",\"dump-create\",\"dump-load\"],\"plist-load\":[\"plist\",\"dep:serde_json\"],\"regex-fancy\":[\"fancy-regex\"],\"regex-onig\":[\"onig\"],\"yaml-load\":[\"yaml-rust\",\"parsing\"]}}", "sys-locale_0.3.2": "{\"dependencies\":[{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(unix)))\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(unix)))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(unix)))\"},{\"features\":[\"Window\",\"WorkerGlobalScope\",\"Navigator\",\"WorkerNavigator\"],\"name\":\"web-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(unix)))\"}],\"features\":{\"js\":[\"js-sys\",\"wasm-bindgen\",\"web-sys\"]}}", "system-configuration-sys_0.6.0": "{\"dependencies\":[{\"name\":\"core-foundation-sys\",\"req\":\"^0.8\"},{\"name\":\"libc\",\"req\":\"^0.2.149\"}],\"features\":{}}", - "system-configuration_0.6.1": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"name\":\"core-foundation\",\"req\":\"^0.9\"},{\"name\":\"system-configuration-sys\",\"req\":\"^0.6\"}],\"features\":{}}", + "system-configuration_0.7.0": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"name\":\"core-foundation\",\"req\":\"^0.9\"},{\"name\":\"system-configuration-sys\",\"req\":\"^0.6\"}],\"features\":{}}", "tagptr_0.2.0": "{\"dependencies\":[],\"features\":{}}", - "tar_0.4.45": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"astral-tokio-tar\",\"req\":\"^0.5\"},{\"name\":\"filetime\",\"req\":\"^0.2.8\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"name\":\"xattr\",\"optional\":true,\"req\":\"^1.1.3\",\"target\":\"cfg(unix)\"}],\"features\":{\"default\":[\"xattr\"]}}", + "tar_0.4.44": "{\"dependencies\":[{\"name\":\"filetime\",\"req\":\"^0.2.8\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"name\":\"xattr\",\"optional\":true,\"req\":\"^1.1.3\",\"target\":\"cfg(unix)\"}],\"features\":{\"default\":[\"xattr\"]}}", "tempfile_3.24.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"fastrand\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"once_cell\",\"req\":\"^1.19.0\"},{\"features\":[\"fs\"],\"name\":\"rustix\",\"req\":\"^1.1.3\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\"))\"},{\"features\":[\"Win32_Storage_FileSystem\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"getrandom\"],\"nightly\":[]}}", + "tempfile_3.27.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"fastrand\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"getrandom\",\"optional\":true,\"req\":\">=0.3.0, <0.5\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"once_cell\",\"req\":\"^1.19.0\"},{\"features\":[\"fs\"],\"name\":\"rustix\",\"req\":\"^1.1.4\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\"))\"},{\"features\":[\"Win32_Storage_FileSystem\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"getrandom\"],\"nightly\":[]}}", "temporal_capi_0.1.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"diplomat\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"name\":\"diplomat-runtime\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"features\":[\"unstable\"],\"name\":\"icu_calendar\",\"req\":\"^2.1.0\"},{\"name\":\"icu_locale\",\"req\":\"^2.1.0\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.19\"},{\"default_features\":false,\"name\":\"temporal_rs\",\"req\":\"^0.1.2\"},{\"name\":\"timezone_provider\",\"req\":\"^0.1.2\"},{\"name\":\"writeable\",\"req\":\"^0.6.0\"},{\"name\":\"zoneinfo64\",\"optional\":true,\"req\":\"^0.2.0\"}],\"features\":{\"compiled_data\":[\"temporal_rs/compiled_data\"],\"zoneinfo64\":[\"dep:zoneinfo64\",\"timezone_provider/zoneinfo64\"]}}", "temporal_rs_0.1.2": "{\"dependencies\":[{\"name\":\"core_maths\",\"req\":\"^0.1.1\"},{\"name\":\"iana-time-zone\",\"optional\":true,\"req\":\"^0.1.64\"},{\"default_features\":false,\"features\":[\"unstable\",\"compiled_data\"],\"name\":\"icu_calendar\",\"req\":\"^2.1.0\"},{\"name\":\"icu_locale\",\"req\":\"^2.1.0\"},{\"features\":[\"duration\"],\"name\":\"ixdtf\",\"req\":\"^0.6.4\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.28\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.19\"},{\"name\":\"timezone_provider\",\"req\":\"^0.1.2\"},{\"name\":\"tinystr\",\"req\":\"^0.8.0\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1.1.0\"},{\"name\":\"writeable\",\"req\":\"^0.6.0\"}],\"features\":{\"compiled_data\":[\"tzdb\"],\"default\":[\"sys\"],\"float64_representable_durations\":[],\"log\":[\"dep:log\"],\"std\":[],\"sys\":[\"std\",\"compiled_data\",\"dep:web-time\",\"dep:iana-time-zone\"],\"tzdb\":[\"std\",\"timezone_provider/tzif\"]}}", "term_0.7.0": "{\"dependencies\":[{\"name\":\"dirs-next\",\"req\":\"^2\"},{\"name\":\"rustversion\",\"req\":\"^1\",\"target\":\"cfg(windows)\"},{\"features\":[\"consoleapi\",\"wincon\",\"handleapi\",\"fileapi\"],\"name\":\"winapi\",\"req\":\"^0.3\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[]}}", @@ -1343,6 +1385,7 @@ "test-case_3.3.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.12\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.11\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.5\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.5\"},{\"default_features\":false,\"name\":\"test-case-macros\",\"req\":\"^3.2.1\"}],\"features\":{\"with-regex\":[\"regex\",\"test-case-macros/with-regex\"]}}", "test-log-macros_0.2.19": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.32\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"full\",\"parsing\",\"printing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{\"log\":[],\"trace\":[],\"unstable\":[]}}", "test-log_0.2.19": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"_lazy_static_unused\",\"package\":\"lazy_static\",\"req\":\"^1.0.2\"},{\"default_features\":false,\"name\":\"env_logger\",\"optional\":true,\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"logging\",\"package\":\"log\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.26\"},{\"kind\":\"dev\",\"name\":\"test-case\",\"req\":\"^3.1\"},{\"name\":\"test-log-macros\",\"req\":\"=0.2.19\"},{\"default_features\":false,\"features\":[\"rt-multi-thread\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"env-filter\",\"fmt\"],\"name\":\"tracing-subscriber\",\"optional\":true,\"req\":\"^0.3.20\"}],\"features\":{\"color\":[\"env_logger?/auto-color\",\"tracing-subscriber?/ansi\"],\"default\":[\"log\",\"color\"],\"log\":[\"dep:env_logger\",\"test-log-macros/log\",\"tracing-subscriber?/tracing-log\"],\"trace\":[\"dep:tracing-subscriber\",\"test-log-macros/trace\"],\"unstable\":[\"test-log-macros/unstable\"]}}", + "tester_0.9.1": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"getopts\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"name\":\"num_cpus\",\"req\":\"^1.13.0\"},{\"name\":\"term\",\"req\":\"^0.7\"}],\"features\":{\"asm_black_box\":[],\"capture\":[]}}", "textwrap_0.11.0": "{\"dependencies\":[{\"features\":[\"embed_all\"],\"name\":\"hyphenation\",\"optional\":true,\"req\":\"^0.7.1\"},{\"kind\":\"dev\",\"name\":\"lipsum\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"rand_xorshift\",\"req\":\"^0.1\"},{\"name\":\"term_size\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"unicode-width\",\"req\":\"^0.1.3\"},{\"kind\":\"dev\",\"name\":\"version-sync\",\"req\":\"^0.6\"}],\"features\":{}}", "textwrap_0.16.2": "{\"dependencies\":[{\"features\":[\"embed_en-us\"],\"name\":\"hyphenation\",\"optional\":true,\"req\":\"^0.8.4\"},{\"name\":\"smawk\",\"optional\":true,\"req\":\"^0.3.2\"},{\"name\":\"terminal_size\",\"optional\":true,\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"termion\",\"req\":\"^4.0.2\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"unic-emoji-char\",\"req\":\"^0.9.0\"},{\"name\":\"unicode-linebreak\",\"optional\":true,\"req\":\"^0.1.5\"},{\"name\":\"unicode-width\",\"optional\":true,\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"version-sync\",\"req\":\"^0.9.5\"}],\"features\":{\"default\":[\"unicode-linebreak\",\"unicode-width\",\"smawk\"]}}", "thiserror-impl_1.0.69": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"name\":\"syn\",\"req\":\"^2.0.87\"}],\"features\":{}}", @@ -1370,10 +1413,12 @@ "tokio_1.49.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.58\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.2.1\"},{\"features\":[\"async-await\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-concurrency\",\"req\":\"^7.6.3\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"io-uring\",\"optional\":true,\"req\":\"^0.7.6\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"features\":[\"futures\",\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0.1\"},{\"default_features\":false,\"features\":[\"os-poll\",\"os-ext\"],\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0.1\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"mio-aio\",\"req\":\"^1\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.13.0\"},{\"default_features\":false,\"features\":[\"aio\",\"fs\",\"socket\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.29.0\",\"target\":\"cfg(unix)\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"signal-hook-registry\",\"optional\":true,\"req\":\"^1.1.1\",\"target\":\"cfg(unix)\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.9\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"name\":\"tokio-macros\",\"optional\":true,\"req\":\"~2.6.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"features\":[\"rt\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\",\"target\":\"cfg(tokio_unstable)\"},{\"kind\":\"dev\",\"name\":\"tracing-mock\",\"req\":\"=0.1.0-beta.1\",\"target\":\"cfg(all(tokio_unstable, target_has_atomic = \\\"64\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.0\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(target_os = \\\"wasi\\\")))\"},{\"name\":\"windows-sys\",\"optional\":true,\"req\":\"^0.61\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_Foundation\",\"Win32_Security_Authorization\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"fs\":[],\"full\":[\"fs\",\"io-util\",\"io-std\",\"macros\",\"net\",\"parking_lot\",\"process\",\"rt\",\"rt-multi-thread\",\"signal\",\"sync\",\"time\"],\"io-std\":[],\"io-uring\":[\"dep:io-uring\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"dep:slab\"],\"io-util\":[\"bytes\"],\"macros\":[\"tokio-macros\"],\"net\":[\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"socket2\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_Security\",\"windows-sys/Win32_Storage_FileSystem\",\"windows-sys/Win32_System_Pipes\",\"windows-sys/Win32_System_SystemServices\"],\"process\":[\"bytes\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Threading\",\"windows-sys/Win32_System_WindowsProgramming\"],\"rt\":[],\"rt-multi-thread\":[\"rt\"],\"signal\":[\"libc\",\"mio/os-poll\",\"mio/net\",\"mio/os-ext\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Console\"],\"sync\":[],\"taskdump\":[\"dep:backtrace\"],\"test-util\":[\"rt\",\"sync\",\"time\"],\"time\":[]}}", "toml_0.5.11": "{\"dependencies\":[{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde\",\"req\":\"^1.0.97\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[],\"preserve_order\":[\"indexmap\"]}}", "toml_0.9.11+spec-1.1.0": "{\"dependencies\":[{\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.20\"},{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.11\"},{\"default_features\":false,\"name\":\"foldhash\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.11.4\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.225\"},{\"kind\":\"dev\",\"name\":\"serde-untagged\",\"req\":\"^0.1.9\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.225\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.145\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_spanned\",\"req\":\"^1.0.4\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.21\"},{\"kind\":\"dev\",\"name\":\"toml-test-data\",\"req\":\"^2.3.3\"},{\"features\":[\"snapshot\"],\"kind\":\"dev\",\"name\":\"toml-test-harness\",\"req\":\"^1.3.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"toml_datetime\",\"req\":\"^0.7.5\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"toml_parser\",\"optional\":true,\"req\":\"^1.0.6\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"toml_writer\",\"optional\":true,\"req\":\"^1.0.6\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"name\":\"winnow\",\"optional\":true,\"req\":\"^0.7.13\"}],\"features\":{\"debug\":[\"std\",\"toml_parser?/debug\",\"dep:anstream\",\"dep:anstyle\"],\"default\":[\"std\",\"serde\",\"parse\",\"display\"],\"display\":[\"dep:toml_writer\"],\"fast_hash\":[\"preserve_order\",\"dep:foldhash\"],\"parse\":[\"dep:toml_parser\",\"dep:winnow\"],\"preserve_order\":[\"dep:indexmap\",\"std\"],\"serde\":[\"dep:serde_core\",\"toml_datetime/serde\",\"serde_spanned/serde\"],\"std\":[\"indexmap?/std\",\"serde_core?/std\",\"toml_parser?/std\",\"toml_writer?/std\",\"toml_datetime/std\",\"serde_spanned/std\"],\"unbounded\":[]}}", + "toml_0.9.12+spec-1.1.0": "{\"dependencies\":[{\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.20\"},{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.11\"},{\"default_features\":false,\"name\":\"foldhash\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.11.4\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.225\"},{\"kind\":\"dev\",\"name\":\"serde-untagged\",\"req\":\"^0.1.9\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.225\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.145\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_spanned\",\"req\":\"^1.0.4\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.21\"},{\"kind\":\"dev\",\"name\":\"toml-test-data\",\"req\":\"^2.3.3\"},{\"features\":[\"snapshot\"],\"kind\":\"dev\",\"name\":\"toml-test-harness\",\"req\":\"^1.3.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"toml_datetime\",\"req\":\"^0.7.5\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"toml_parser\",\"optional\":true,\"req\":\"^1.0.7\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"toml_writer\",\"optional\":true,\"req\":\"^1.0.6\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"name\":\"winnow\",\"optional\":true,\"req\":\"^0.7.13\"}],\"features\":{\"debug\":[\"std\",\"toml_parser?/debug\",\"dep:anstream\",\"dep:anstyle\"],\"default\":[\"std\",\"serde\",\"parse\",\"display\"],\"display\":[\"dep:toml_writer\"],\"fast_hash\":[\"preserve_order\",\"dep:foldhash\"],\"parse\":[\"dep:toml_parser\",\"dep:winnow\"],\"preserve_order\":[\"dep:indexmap\",\"std\"],\"serde\":[\"dep:serde_core\",\"toml_datetime/serde\",\"serde_spanned/serde\"],\"std\":[\"indexmap?/std\",\"serde_core?/std\",\"toml_parser?/std\",\"toml_writer?/std\",\"toml_datetime/std\",\"serde_spanned/std\"],\"unbounded\":[]}}", "toml_datetime_0.7.5+spec-1.1.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.225\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.21\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\"],\"default\":[\"std\"],\"serde\":[\"dep:serde_core\"],\"std\":[\"alloc\",\"serde_core?/std\"]}}", "toml_edit_0.23.10+spec-1.0.0": "{\"dependencies\":[{\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.20\"},{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.11\"},{\"features\":[\"std\"],\"name\":\"indexmap\",\"req\":\"^2.11.4\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.7.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.225\"},{\"kind\":\"dev\",\"name\":\"serde-untagged\",\"req\":\"^0.1.9\"},{\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.225\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.145\"},{\"features\":[\"serde\"],\"name\":\"serde_spanned\",\"optional\":true,\"req\":\"^1.0.4\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.21\"},{\"kind\":\"dev\",\"name\":\"toml-test-data\",\"req\":\"^2.3.3\"},{\"features\":[\"snapshot\"],\"kind\":\"dev\",\"name\":\"toml-test-harness\",\"req\":\"^1.3.3\"},{\"name\":\"toml_datetime\",\"req\":\"^0.7.4\"},{\"name\":\"toml_parser\",\"optional\":true,\"req\":\"^1.0.5\"},{\"name\":\"toml_writer\",\"optional\":true,\"req\":\"^1.0.5\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5.0\"},{\"name\":\"winnow\",\"optional\":true,\"req\":\"^0.7.13\"}],\"features\":{\"debug\":[\"toml_parser?/debug\",\"dep:anstream\",\"dep:anstyle\",\"display\"],\"default\":[\"parse\",\"display\"],\"display\":[\"dep:toml_writer\"],\"parse\":[\"dep:toml_parser\",\"dep:winnow\"],\"serde\":[\"dep:serde_core\",\"toml_datetime/serde\",\"dep:serde_spanned\"],\"unbounded\":[]}}", "toml_edit_0.24.0+spec-1.1.0": "{\"dependencies\":[{\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.20\"},{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.11\"},{\"features\":[\"std\"],\"name\":\"indexmap\",\"req\":\"^2.11.4\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.7.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.225\"},{\"kind\":\"dev\",\"name\":\"serde-untagged\",\"req\":\"^0.1.9\"},{\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.225\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.145\"},{\"features\":[\"serde\"],\"name\":\"serde_spanned\",\"optional\":true,\"req\":\"^1.0.4\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.21\"},{\"kind\":\"dev\",\"name\":\"toml-test-data\",\"req\":\"^2.3.3\"},{\"features\":[\"snapshot\"],\"kind\":\"dev\",\"name\":\"toml-test-harness\",\"req\":\"^1.3.3\"},{\"name\":\"toml_datetime\",\"req\":\"^0.7.5\"},{\"name\":\"toml_parser\",\"optional\":true,\"req\":\"^1.0.6\"},{\"name\":\"toml_writer\",\"optional\":true,\"req\":\"^1.0.6\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5.0\"},{\"name\":\"winnow\",\"optional\":true,\"req\":\"^0.7.13\"}],\"features\":{\"debug\":[\"toml_parser?/debug\",\"dep:anstream\",\"dep:anstyle\",\"display\"],\"default\":[\"parse\",\"display\"],\"display\":[\"dep:toml_writer\"],\"parse\":[\"dep:toml_parser\",\"dep:winnow\"],\"serde\":[\"dep:serde_core\",\"toml_datetime/serde\",\"dep:serde_spanned\"],\"unbounded\":[]}}", "toml_parser_1.0.6+spec-1.1.0": "{\"dependencies\":[{\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.20\"},{\"features\":[\"test\"],\"kind\":\"dev\",\"name\":\"anstream\",\"req\":\"^0.6.20\"},{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.11\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.21\"},{\"default_features\":false,\"name\":\"winnow\",\"req\":\"^0.7.13\"}],\"features\":{\"alloc\":[],\"debug\":[\"std\",\"dep:anstream\",\"dep:anstyle\"],\"default\":[\"std\"],\"simd\":[\"winnow/simd\"],\"std\":[\"alloc\"],\"unsafe\":[]}}", + "toml_parser_1.0.9+spec-1.1.0": "{\"dependencies\":[{\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.20\"},{\"features\":[\"test\"],\"kind\":\"dev\",\"name\":\"anstream\",\"req\":\"^0.6.20\"},{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.11\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.21\"},{\"default_features\":false,\"name\":\"winnow\",\"req\":\"^0.7.13\"}],\"features\":{\"alloc\":[],\"debug\":[\"std\",\"dep:anstream\",\"dep:anstyle\"],\"default\":[\"std\"],\"simd\":[\"winnow/simd\"],\"std\":[\"alloc\"],\"unsafe\":[]}}", "toml_writer_1.0.6+spec-1.1.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.7.0\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.21\"},{\"kind\":\"dev\",\"name\":\"toml_old\",\"package\":\"toml\",\"req\":\"^0.5.11\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", "tonic-prost_0.14.3": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"http-body\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"name\":\"prost\",\"req\":\"^0.14\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"tonic\",\"req\":\"^0.14.0\"}],\"features\":{}}", "tonic_0.14.3": "{\"dependencies\":[{\"name\":\"async-trait\",\"optional\":true,\"req\":\"^0.1.13\"},{\"default_features\":false,\"name\":\"axum\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"bytes\",\"req\":\"^1.0\"},{\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"http\",\"req\":\"^1.1.0\"},{\"name\":\"http-body\",\"req\":\"^1\"},{\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"features\":[\"http1\",\"http2\"],\"name\":\"hyper\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"hyper-timeout\",\"optional\":true,\"req\":\"^0.5\"},{\"features\":[\"tokio\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1.11\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1\"},{\"name\":\"pin-project\",\"req\":\"^1.0.11\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.0\"},{\"name\":\"sync_wrapper\",\"req\":\"^1.0.2\"},{\"default_features\":false,\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"rt-multi-thread\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"logging\",\"tls12\"],\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26.1\"},{\"default_features\":false,\"name\":\"tokio-stream\",\"req\":\"^0.1.16\"},{\"default_features\":false,\"name\":\"tower\",\"optional\":true,\"req\":\"^0.5\"},{\"features\":[\"load-shed\",\"timeout\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5\"},{\"name\":\"tower-layer\",\"req\":\"^0.3\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"zstd\",\"optional\":true,\"req\":\"^0.13.0\"}],\"features\":{\"_tls-any\":[\"dep:tokio\",\"tokio?/rt\",\"tokio?/macros\",\"tls-connect-info\"],\"channel\":[\"dep:hyper\",\"hyper?/client\",\"dep:hyper-util\",\"hyper-util?/client-legacy\",\"dep:tower\",\"tower?/balance\",\"tower?/buffer\",\"tower?/discover\",\"tower?/limit\",\"tower?/load-shed\",\"tower?/util\",\"dep:tokio\",\"tokio?/time\",\"dep:hyper-timeout\"],\"codegen\":[\"dep:async-trait\"],\"default\":[\"router\",\"transport\",\"codegen\"],\"deflate\":[\"dep:flate2\"],\"gzip\":[\"dep:flate2\"],\"router\":[\"dep:axum\",\"dep:tower\",\"tower?/util\"],\"server\":[\"dep:h2\",\"dep:hyper\",\"hyper?/server\",\"dep:hyper-util\",\"hyper-util?/service\",\"hyper-util?/server-auto\",\"dep:socket2\",\"dep:tokio\",\"tokio?/macros\",\"tokio?/net\",\"tokio?/time\",\"tokio-stream/net\",\"dep:tower\",\"tower?/util\",\"tower?/limit\",\"tower?/load-shed\"],\"tls-aws-lc\":[\"_tls-any\",\"tokio-rustls/aws-lc-rs\"],\"tls-connect-info\":[\"dep:tokio-rustls\"],\"tls-native-roots\":[\"_tls-any\",\"channel\",\"dep:rustls-native-certs\"],\"tls-ring\":[\"_tls-any\",\"tokio-rustls/ring\"],\"tls-webpki-roots\":[\"_tls-any\",\"channel\",\"dep:webpki-roots\"],\"transport\":[\"server\",\"channel\"],\"zstd\":[\"dep:zstd\"]}}", @@ -1410,6 +1455,7 @@ "unicase_2.9.0": "{\"dependencies\":[],\"features\":{\"nightly\":[]}}", "unicode-bidi_0.3.18": "{\"dependencies\":[{\"name\":\"flame\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"flamer\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\">=0.8, <2.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\">=0.8, <2.0\"},{\"features\":[\"union\"],\"name\":\"smallvec\",\"optional\":true,\"req\":\">=1.13\"}],\"features\":{\"bench_it\":[],\"default\":[\"std\",\"hardcoded-data\"],\"flame_it\":[\"flame\",\"flamer\"],\"hardcoded-data\":[],\"std\":[],\"unstable\":[],\"with_serde\":[\"serde\"]}}", "unicode-ident_1.0.22": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"fst\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"roaring\",\"req\":\"^0.11\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"ucd-trie\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"unicode-xid\",\"req\":\"^0.2.6\"}],\"features\":{}}", + "unicode-ident_1.0.24": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"fst\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"roaring\",\"req\":\"^0.11\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"ucd-trie\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"unicode-xid\",\"req\":\"^0.2.6\"}],\"features\":{}}", "unicode-linebreak_0.1.5": "{\"dependencies\":[],\"features\":{}}", "unicode-normalization_0.1.25": "{\"dependencies\":[{\"features\":[\"alloc\"],\"name\":\"tinyvec\",\"req\":\"^1\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "unicode-properties_0.1.4": "{\"dependencies\":[],\"features\":{\"default\":[\"general-category\",\"emoji\"],\"emoji\":[],\"general-category\":[]}}", @@ -1417,6 +1463,7 @@ "unicode-truncate_1.1.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"itertools\",\"req\":\"^0.13\"},{\"default_features\":false,\"name\":\"unicode-segmentation\",\"req\":\"^1\"},{\"name\":\"unicode-width\",\"req\":\"^0.1\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "unicode-width_0.1.14": "{\"dependencies\":[{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"name\":\"std\",\"optional\":true,\"package\":\"rustc-std-workspace-std\",\"req\":\"^1.0\"}],\"features\":{\"cjk\":[],\"default\":[\"cjk\"],\"no_std\":[],\"rustc-dep-of-std\":[\"std\",\"core\",\"compiler_builtins\"]}}", "unicode-width_0.2.1": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"name\":\"std\",\"optional\":true,\"package\":\"rustc-std-workspace-std\",\"req\":\"^1.0\"}],\"features\":{\"cjk\":[],\"default\":[\"cjk\"],\"no_std\":[],\"rustc-dep-of-std\":[\"std\",\"core\"]}}", + "unicode-width_0.2.2": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"name\":\"std\",\"optional\":true,\"package\":\"rustc-std-workspace-std\",\"req\":\"^1.0\"}],\"features\":{\"cjk\":[],\"default\":[\"cjk\"],\"no_std\":[],\"rustc-dep-of-std\":[\"std\",\"core\"]}}", "unicode-xid_0.2.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"}],\"features\":{\"bench\":[],\"default\":[],\"no_std\":[]}}", "universal-hash_0.5.1": "{\"dependencies\":[{\"name\":\"crypto-common\",\"req\":\"^0.1.6\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.4\"}],\"features\":{\"std\":[\"crypto-common/std\"]}}", "unsafe-libyaml_0.2.11": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.0\"}],\"features\":{}}", @@ -1441,13 +1488,17 @@ "want_0.3.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"tokio-executor\",\"req\":\"^0.2.0-alpha.2\"},{\"kind\":\"dev\",\"name\":\"tokio-sync\",\"req\":\"^0.2.0-alpha.2\"},{\"name\":\"try-lock\",\"req\":\"^0.2.4\"}],\"features\":{}}", "wasi_0.11.1+wasi-snapshot-preview1": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"name\":\"rustc-std-workspace-alloc\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"rustc-dep-of-std\":[\"core\",\"rustc-std-workspace-alloc\"],\"std\":[]}}", "wasip2_1.0.2+wasi-0.2.9": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"wit-bindgen\",\"req\":\"^0.51.0\"}],\"features\":{\"bitflags\":[\"wit-bindgen/bitflags\"],\"default\":[\"std\",\"bitflags\"],\"rustc-dep-of-std\":[\"core\",\"alloc\",\"wit-bindgen/rustc-dep-of-std\"],\"std\":[]}}", + "wasip3_0.4.0+wasi-0.3.0-rc-2026-01-06": "{\"dependencies\":[{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.10.1\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.31\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"http\",\"req\":\"^1.3.1\"},{\"name\":\"http-body\",\"optional\":true,\"req\":\"^1.0.1\"},{\"name\":\"thiserror\",\"optional\":true,\"req\":\"^2.0.17\"},{\"default_features\":false,\"features\":[\"async\"],\"name\":\"wit-bindgen\",\"req\":\"^0.51.0\"},{\"default_features\":false,\"features\":[\"async-spawn\"],\"kind\":\"dev\",\"name\":\"wit-bindgen\",\"req\":\"^0.51.0\"}],\"features\":{\"http-compat\":[\"dep:bytes\",\"dep:http-body\",\"dep:http\",\"dep:thiserror\",\"wit-bindgen/async-spawn\"]}}", "wasite_0.1.0": "{\"dependencies\":[],\"features\":{}}", "wasm-bindgen-futures_0.4.58": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3.8\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"js-sys\",\"req\":\"=0.3.85\"},{\"default_features\":false,\"name\":\"once_cell\",\"req\":\"^1.12\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"req\":\"=0.2.108\"},{\"default_features\":false,\"features\":[\"MessageEvent\",\"Worker\"],\"name\":\"web-sys\",\"req\":\"=0.3.85\",\"target\":\"cfg(target_feature = \\\"atomics\\\")\"}],\"features\":{\"default\":[\"std\"],\"futures-core-03-stream\":[\"futures-core\"],\"std\":[\"wasm-bindgen/std\",\"js-sys/std\",\"web-sys/std\",\"futures-util\"]}}", "wasm-bindgen-macro-support_0.2.108": "{\"dependencies\":[{\"name\":\"bumpalo\",\"req\":\"^3.0.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"visit\",\"visit-mut\",\"full\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"name\":\"wasm-bindgen-shared\",\"req\":\"=0.2.108\"}],\"features\":{\"extra-traits\":[\"syn/extra-traits\"],\"strict-macro\":[]}}", "wasm-bindgen-macro_0.2.108": "{\"dependencies\":[{\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"},{\"name\":\"wasm-bindgen-macro-support\",\"req\":\"=0.2.108\"}],\"features\":{\"strict-macro\":[\"wasm-bindgen-macro-support/strict-macro\"]}}", "wasm-bindgen-shared_0.2.108": "{\"dependencies\":[{\"name\":\"unicode-ident\",\"req\":\"^1.0.5\"}],\"features\":{}}", "wasm-bindgen_0.2.108": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"once_cell\",\"req\":\"^1.12\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"build\",\"name\":\"rustversion-compat\",\"package\":\"rustversion\",\"req\":\"^1.0.6\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"wasm-bindgen-macro\",\"req\":\"=0.2.108\"},{\"name\":\"wasm-bindgen-shared\",\"req\":\"=0.2.108\"}],\"features\":{\"default\":[\"std\"],\"enable-interning\":[\"std\"],\"gg-alloc\":[],\"msrv\":[],\"rustversion\":[],\"serde-serialize\":[\"serde\",\"serde_json\",\"std\"],\"spans\":[],\"std\":[],\"strict-macro\":[\"wasm-bindgen-macro/strict-macro\"],\"xxx_debug_only_print_generated_code\":[]}}", + "wasm-encoder_0.244.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"default_features\":false,\"name\":\"leb128fmt\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.2.0\"},{\"default_features\":false,\"features\":[\"simd\",\"simd\"],\"name\":\"wasmparser\",\"optional\":true,\"req\":\"^0.244.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"wasmprinter\",\"req\":\"^0.244.0\"}],\"features\":{\"component-model\":[\"wasmparser?/component-model\"],\"default\":[\"std\",\"component-model\"],\"std\":[\"wasmparser?/std\"]}}", + "wasm-metadata_0.244.0": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"name\":\"auditable-serde\",\"optional\":true,\"req\":\"^0.8.0\"},{\"features\":[\"derive\"],\"name\":\"clap\",\"optional\":true,\"req\":\"^4.0.0\"},{\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"serde\"],\"name\":\"indexmap\",\"req\":\"^2.7.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.166\"},{\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0.166\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"spdx\",\"optional\":true,\"req\":\"^0.10.1\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"features\":[\"std\",\"component-model\"],\"name\":\"wasm-encoder\",\"req\":\"^0.244.0\"},{\"default_features\":false,\"features\":[\"simd\",\"std\",\"component-model\",\"hash-collections\"],\"name\":\"wasmparser\",\"req\":\"^0.244.0\"}],\"features\":{\"default\":[\"oci\",\"serde\"],\"oci\":[\"dep:auditable-serde\",\"dep:flate2\",\"dep:url\",\"dep:spdx\",\"dep:serde_json\",\"serde\"],\"serde\":[\"dep:serde_derive\",\"dep:serde\"]}}", "wasm-streams_0.4.2": "{\"dependencies\":[{\"features\":[\"io\",\"sink\"],\"name\":\"futures-util\",\"req\":\"^0.3.31\"},{\"features\":[\"futures\"],\"kind\":\"dev\",\"name\":\"gloo-timers\",\"req\":\"^0.3.0\"},{\"name\":\"js-sys\",\"req\":\"^0.3.72\"},{\"kind\":\"dev\",\"name\":\"pin-project\",\"req\":\"^1\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.95\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.45\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.45\"},{\"features\":[\"AbortSignal\",\"QueuingStrategy\",\"ReadableStream\",\"ReadableStreamType\",\"ReadableWritablePair\",\"ReadableStreamByobReader\",\"ReadableStreamReaderMode\",\"ReadableStreamReadResult\",\"ReadableStreamByobRequest\",\"ReadableStreamDefaultReader\",\"ReadableByteStreamController\",\"ReadableStreamGetReaderOptions\",\"ReadableStreamDefaultController\",\"StreamPipeOptions\",\"TransformStream\",\"TransformStreamDefaultController\",\"Transformer\",\"UnderlyingSink\",\"UnderlyingSource\",\"WritableStream\",\"WritableStreamDefaultController\",\"WritableStreamDefaultWriter\"],\"name\":\"web-sys\",\"req\":\"^0.3.72\"},{\"features\":[\"console\",\"AbortSignal\",\"ErrorEvent\",\"PromiseRejectionEvent\",\"Response\",\"ReadableStream\",\"Window\"],\"kind\":\"dev\",\"name\":\"web-sys\",\"req\":\"^0.3.72\"}],\"features\":{}}", + "wasmparser_0.244.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"name\":\"bitflags\",\"req\":\"^2.4.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"default-hasher\"],\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15.2\"},{\"default_features\":false,\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.7.0\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.17\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.13.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.3\"},{\"default_features\":false,\"name\":\"semver\",\"optional\":true,\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.166\"}],\"features\":{\"component-model\":[\"dep:semver\"],\"default\":[\"std\",\"validate\",\"serde\",\"features\",\"component-model\",\"hash-collections\",\"simd\"],\"features\":[],\"hash-collections\":[\"dep:hashbrown\",\"dep:indexmap\"],\"prefer-btree-collections\":[],\"serde\":[\"dep:serde\",\"indexmap?/serde\",\"hashbrown?/serde\"],\"simd\":[],\"std\":[\"indexmap?/std\"],\"validate\":[]}}", "wayland-backend_0.3.12": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"concat-idents\",\"req\":\"^1.1\"},{\"name\":\"downcast-rs\",\"req\":\"^1.2\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"raw-window-handle\",\"optional\":true,\"req\":\"^0.5.0\"},{\"features\":[\"event\",\"fs\",\"net\",\"process\"],\"name\":\"rustix\",\"req\":\"^1.0.2\"},{\"name\":\"rwh_06\",\"optional\":true,\"package\":\"raw-window-handle\",\"req\":\"^0.6.0\"},{\"name\":\"scoped-tls\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"scoped-tls\",\"req\":\"^1.0\"},{\"features\":[\"union\",\"const_generics\",\"const_new\"],\"name\":\"smallvec\",\"req\":\"^1.9\"},{\"name\":\"wayland-sys\",\"req\":\"^0.31.8\"}],\"features\":{\"client_system\":[\"wayland-sys/client\",\"dep:scoped-tls\"],\"dlopen\":[\"wayland-sys/dlopen\"],\"server_system\":[\"wayland-sys/server\",\"dep:scoped-tls\"]}}", "wayland-client_0.31.12": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3.16\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"event\"],\"name\":\"rustix\",\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.2\"},{\"name\":\"wayland-backend\",\"req\":\"^0.3.12\"},{\"name\":\"wayland-scanner\",\"req\":\"^0.31.8\"}],\"features\":{}}", "wayland-protocols-wlr_0.3.10": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"name\":\"wayland-backend\",\"req\":\"^0.3.12\"},{\"name\":\"wayland-client\",\"optional\":true,\"req\":\"^0.31.12\"},{\"name\":\"wayland-protocols\",\"req\":\"^0.32.10\"},{\"name\":\"wayland-scanner\",\"req\":\"^0.31.8\"},{\"name\":\"wayland-server\",\"optional\":true,\"req\":\"^0.31.11\"}],\"features\":{\"client\":[\"wayland-client\",\"wayland-protocols/client\"],\"server\":[\"wayland-server\",\"wayland-protocols/server\"]}}", @@ -1533,13 +1584,19 @@ "windows_x86_64_msvc_0.52.6": "{\"dependencies\":[],\"features\":{}}", "windows_x86_64_msvc_0.53.1": "{\"dependencies\":[],\"features\":{}}", "winnow_0.7.14": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"annotate-snippets\",\"req\":\"^0.11.4\"},{\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.15\"},{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.8\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.100\"},{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.15\"},{\"kind\":\"dev\",\"name\":\"circular\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"name\":\"is_terminal_polyfill\",\"optional\":true,\"req\":\"^1.48.1\"},{\"kind\":\"dev\",\"name\":\"lexopt\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.7\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.6.0\"},{\"kind\":\"dev\",\"name\":\"rustc-hash\",\"req\":\"^2.1.1\"},{\"features\":[\"examples\"],\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.21\"},{\"kind\":\"dev\",\"name\":\"term-transcript\",\"req\":\"^0.2.0\"},{\"name\":\"terminal_size\",\"optional\":true,\"req\":\"^0.4.3\"}],\"features\":{\"alloc\":[],\"debug\":[\"std\",\"dep:anstream\",\"dep:anstyle\",\"dep:is_terminal_polyfill\",\"dep:terminal_size\"],\"default\":[\"std\"],\"simd\":[\"dep:memchr\"],\"std\":[\"alloc\",\"memchr?/std\"],\"unstable-doc\":[\"alloc\",\"std\",\"simd\",\"unstable-recover\"],\"unstable-recover\":[]}}", + "winnow_0.7.15": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"annotate-snippets\",\"req\":\"^0.11.4\"},{\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.15\"},{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.8\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.100\"},{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.15\"},{\"kind\":\"dev\",\"name\":\"circular\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"name\":\"is_terminal_polyfill\",\"optional\":true,\"req\":\"^1.48.1\"},{\"kind\":\"dev\",\"name\":\"lexopt\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.7\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.6.0\"},{\"kind\":\"dev\",\"name\":\"rustc-hash\",\"req\":\"^2.1.1\"},{\"features\":[\"examples\"],\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.21\"},{\"kind\":\"dev\",\"name\":\"term-transcript\",\"req\":\"^0.2.0\"},{\"name\":\"terminal_size\",\"optional\":true,\"req\":\"^0.4.3\"}],\"features\":{\"alloc\":[],\"debug\":[\"std\",\"dep:anstream\",\"dep:anstyle\",\"dep:is_terminal_polyfill\",\"dep:terminal_size\"],\"default\":[\"std\"],\"simd\":[\"dep:memchr\"],\"std\":[\"alloc\",\"memchr?/std\"],\"unstable-doc\":[\"alloc\",\"std\",\"simd\",\"unstable-recover\"],\"unstable-recover\":[]}}", "winreg_0.10.1": "{\"dependencies\":[{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.6\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.3\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"~3.0\"},{\"features\":[\"impl-default\",\"impl-debug\",\"minwindef\",\"minwinbase\",\"timezoneapi\",\"winerror\",\"winnt\",\"winreg\",\"handleapi\"],\"name\":\"winapi\",\"req\":\"^0.3.9\"}],\"features\":{\"serialization-serde\":[\"transactions\",\"serde\"],\"transactions\":[\"winapi/ktmw32\"]}}", "winreg_0.50.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.6\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.3\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_bytes\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"~3.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Time\",\"Win32_System_Registry\",\"Win32_Security\",\"Win32_Storage_FileSystem\",\"Win32_System_Diagnostics_Debug\"],\"name\":\"windows-sys\",\"req\":\"^0.48.0\"}],\"features\":{\"serialization-serde\":[\"transactions\",\"serde\"],\"transactions\":[]}}", "winres_0.1.12": "{\"dependencies\":[{\"name\":\"toml\",\"req\":\"^0.5\"},{\"features\":[\"winnt\"],\"kind\":\"dev\",\"name\":\"winapi\",\"req\":\"^0.3\"}],\"features\":{}}", "winsafe_0.0.19": "{\"dependencies\":[],\"features\":{\"comctl\":[\"ole\"],\"dshow\":[\"oleaut\"],\"dwm\":[\"uxtheme\"],\"dxgi\":[\"ole\"],\"gdi\":[\"user\"],\"gui\":[\"comctl\",\"shell\",\"uxtheme\"],\"kernel\":[],\"mf\":[\"oleaut\"],\"ole\":[\"user\"],\"oleaut\":[\"ole\"],\"shell\":[\"oleaut\"],\"taskschd\":[\"oleaut\"],\"user\":[\"kernel\"],\"uxtheme\":[\"gdi\",\"ole\"],\"version\":[\"kernel\"]}}", "winsplit_0.1.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.3\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "wiremock_0.6.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-rt\",\"req\":\"^2.10.0\"},{\"name\":\"assert-json-diff\",\"req\":\"^2.0.2\"},{\"features\":[\"attributes\",\"tokio1\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.13.2\"},{\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"deadpool\",\"req\":\"^0.12.2\"},{\"name\":\"futures\",\"req\":\"^0.3.31\"},{\"name\":\"http\",\"req\":\"^1.3\"},{\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"features\":[\"full\"],\"name\":\"hyper\",\"req\":\"^1.7\"},{\"features\":[\"tokio\",\"server\",\"http1\",\"http2\"],\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"once_cell\",\"req\":\"^1\"},{\"name\":\"regex\",\"req\":\"^1\"},{\"features\":[\"json\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.12.23\"},{\"name\":\"serde\",\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1\"},{\"features\":[\"rt\",\"macros\",\"net\"],\"name\":\"tokio\",\"req\":\"^1.47.1\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.47.1\"},{\"name\":\"url\",\"req\":\"^2.5\"}],\"features\":{}}", + "wit-bindgen-core_0.51.0": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.72\"},{\"features\":[\"derive\"],\"name\":\"clap\",\"optional\":true,\"req\":\"^4.3.19\"},{\"name\":\"heck\",\"req\":\"^0.5\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.218\"},{\"name\":\"wit-parser\",\"req\":\"^0.244.0\"}],\"features\":{\"clap\":[\"dep:clap\"],\"serde\":[\"dep:serde\"]}}", + "wit-bindgen-rust-macro_0.51.0": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.72\"},{\"name\":\"prettyplease\",\"req\":\"^0.2.20\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"printing\"],\"name\":\"syn\",\"req\":\"^2.0.89\"},{\"name\":\"wit-bindgen-core\",\"req\":\"^0.51.0\"},{\"name\":\"wit-bindgen-rust\",\"req\":\"^0.51.0\"}],\"features\":{\"async\":[]}}", + "wit-bindgen-rust_0.51.0": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.72\"},{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\"},{\"features\":[\"derive\"],\"name\":\"clap\",\"optional\":true,\"req\":\"^4.3.19\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.31\"},{\"name\":\"heck\",\"req\":\"^0.5\"},{\"name\":\"indexmap\",\"req\":\"^2.0.0\"},{\"name\":\"prettyplease\",\"req\":\"^0.2.20\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.218\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"features\":[\"printing\"],\"name\":\"syn\",\"req\":\"^2.0.89\"},{\"default_features\":false,\"name\":\"wasm-metadata\",\"req\":\"^0.244.0\"},{\"name\":\"wit-bindgen-core\",\"req\":\"^0.51.0\"},{\"name\":\"wit-component\",\"req\":\"^0.244.0\"}],\"features\":{\"clap\":[\"dep:clap\",\"wit-bindgen-core/clap\"],\"serde\":[\"dep:serde\",\"wit-bindgen-core/serde\"]}}", "wit-bindgen_0.51.0": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0\"},{\"name\":\"bitflags\",\"optional\":true,\"req\":\"^2.3.3\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3.30\"},{\"name\":\"wit-bindgen-rust-macro\",\"optional\":true,\"req\":\"^0.51.0\"}],\"features\":{\"async\":[\"std\",\"wit-bindgen-rust-macro?/async\"],\"async-spawn\":[\"async\",\"dep:futures\"],\"bitflags\":[\"dep:bitflags\"],\"default\":[\"macros\",\"realloc\",\"async\",\"std\",\"bitflags\"],\"inter-task-wakeup\":[\"async\"],\"macros\":[\"dep:wit-bindgen-rust-macro\"],\"realloc\":[],\"rustc-dep-of-std\":[\"dep:core\",\"dep:alloc\"],\"std\":[]}}", + "wit-component_0.244.0": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"name\":\"bitflags\",\"req\":\"^2.3.3\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3.0\"},{\"default_features\":false,\"name\":\"indexmap\",\"req\":\"^2.7.0\"},{\"kind\":\"dev\",\"name\":\"libtest-mimic\",\"req\":\"^0.8.1\"},{\"name\":\"log\",\"req\":\"^0.4.17\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.3.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"req\":\"^1.0.166\"},{\"name\":\"serde_derive\",\"req\":\"^1.0.166\"},{\"name\":\"serde_json\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\",\"wasmparser\"],\"name\":\"wasm-encoder\",\"req\":\"^0.244.0\"},{\"default_features\":false,\"name\":\"wasm-metadata\",\"req\":\"^0.244.0\"},{\"default_features\":false,\"features\":[\"oci\"],\"kind\":\"dev\",\"name\":\"wasm-metadata\",\"req\":\"^0.244.0\"},{\"default_features\":false,\"features\":[\"simd\",\"std\",\"component-model\",\"simd\"],\"name\":\"wasmparser\",\"req\":\"^0.244.0\"},{\"default_features\":false,\"features\":[\"simd\",\"std\",\"component-model\",\"features\"],\"kind\":\"dev\",\"name\":\"wasmparser\",\"req\":\"^0.244.0\"},{\"default_features\":false,\"features\":[\"component-model\"],\"kind\":\"dev\",\"name\":\"wasmprinter\",\"req\":\"^0.244.0\"},{\"default_features\":false,\"features\":[\"cranelift\",\"component-model\",\"runtime\",\"gc-drc\"],\"kind\":\"dev\",\"name\":\"wasmtime\",\"req\":\"^34.0.1\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"default_features\":false,\"name\":\"wast\",\"optional\":true,\"req\":\"^244.0.0\"},{\"default_features\":false,\"name\":\"wat\",\"optional\":true,\"req\":\"^1.244.0\"},{\"default_features\":false,\"features\":[\"component-model\"],\"kind\":\"dev\",\"name\":\"wat\",\"req\":\"^1.244.0\"},{\"features\":[\"decoding\",\"serde\"],\"name\":\"wit-parser\",\"req\":\"^0.244.0\"}],\"features\":{\"dummy-module\":[\"dep:wat\"],\"semver-check\":[\"dummy-module\"],\"wat\":[\"dep:wast\",\"dep:wat\"]}}", + "wit-parser_0.244.0": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"id-arena\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"indexmap\",\"req\":\"^2.7.0\"},{\"kind\":\"dev\",\"name\":\"libtest-mimic\",\"req\":\"^0.8.1\"},{\"name\":\"log\",\"req\":\"^0.4.17\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.3.0\"},{\"default_features\":false,\"name\":\"semver\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.166\"},{\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0.166\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"unicode-xid\",\"req\":\"^0.2.2\"},{\"default_features\":false,\"features\":[\"simd\",\"std\",\"validate\",\"component-model\",\"features\"],\"name\":\"wasmparser\",\"optional\":true,\"req\":\"^0.244.0\"},{\"default_features\":false,\"features\":[\"component-model\"],\"name\":\"wat\",\"optional\":true,\"req\":\"^1.244.0\"}],\"features\":{\"decoding\":[\"dep:wasmparser\"],\"default\":[\"serde\",\"decoding\"],\"serde\":[\"dep:serde\",\"dep:serde_derive\",\"indexmap/serde\",\"serde_json\"],\"wat\":[\"decoding\",\"dep:wat\"]}}", "wl-clipboard-rs_0.9.3": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.168\"},{\"name\":\"log\",\"req\":\"^0.4.11\"},{\"features\":[\"io_safety\"],\"name\":\"os_pipe\",\"req\":\"^1.1\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"proptest-derive\",\"req\":\"^0.7\"},{\"features\":[\"fs\",\"event\"],\"name\":\"rustix\",\"req\":\"^1.0.2\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"name\":\"tree_magic_mini\",\"req\":\"^3\"},{\"name\":\"wayland-backend\",\"req\":\"^0.3.11\"},{\"name\":\"wayland-client\",\"req\":\"^0.31.11\"},{\"features\":[\"client\",\"staging\"],\"name\":\"wayland-protocols\",\"req\":\"^0.32.9\"},{\"features\":[\"server\",\"staging\"],\"kind\":\"dev\",\"name\":\"wayland-protocols\",\"req\":\"^0.32.9\"},{\"features\":[\"client\"],\"name\":\"wayland-protocols-wlr\",\"req\":\"^0.3.9\"},{\"features\":[\"server\"],\"kind\":\"dev\",\"name\":\"wayland-protocols-wlr\",\"req\":\"^0.3.9\"},{\"kind\":\"dev\",\"name\":\"wayland-server\",\"req\":\"^0.31.10\"}],\"features\":{\"dlopen\":[\"native_lib\",\"wayland-backend/dlopen\",\"wayland-backend/dlopen\"],\"native_lib\":[\"wayland-backend/client_system\",\"wayland-backend/server_system\"]}}", "writeable_0.6.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"either\",\"optional\":true,\"req\":\"^1.9.0\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"}],\"features\":{\"alloc\":[],\"default\":[\"alloc\"],\"either\":[\"dep:either\"]}}", "x11rb-protocol_0.13.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"all-extensions\":[\"composite\",\"damage\",\"dbe\",\"dpms\",\"dri2\",\"dri3\",\"glx\",\"present\",\"randr\",\"record\",\"render\",\"res\",\"screensaver\",\"shape\",\"shm\",\"sync\",\"xevie\",\"xf86dri\",\"xf86vidmode\",\"xfixes\",\"xinerama\",\"xinput\",\"xkb\",\"xprint\",\"xselinux\",\"xtest\",\"xv\",\"xvmc\"],\"composite\":[\"xfixes\"],\"damage\":[\"xfixes\"],\"dbe\":[],\"default\":[\"std\"],\"dpms\":[],\"dri2\":[],\"dri3\":[],\"extra-traits\":[],\"glx\":[],\"present\":[\"randr\",\"xfixes\",\"sync\",\"dri3\"],\"randr\":[\"render\"],\"record\":[],\"render\":[],\"request-parsing\":[],\"res\":[],\"resource_manager\":[\"std\"],\"screensaver\":[],\"shape\":[],\"shm\":[],\"std\":[],\"sync\":[],\"xevie\":[],\"xf86dri\":[],\"xf86vidmode\":[],\"xfixes\":[\"render\",\"shape\"],\"xinerama\":[],\"xinput\":[\"xfixes\"],\"xkb\":[],\"xprint\":[],\"xselinux\":[],\"xtest\":[],\"xv\":[\"shm\"],\"xvmc\":[\"xv\"]}}", @@ -1568,6 +1625,7 @@ "zerovec_0.11.5": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"xxhash64\"],\"name\":\"twox-hash\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.0\"},{\"default_features\":false,\"name\":\"zerofrom\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"zerovec-derive\",\"optional\":true,\"req\":\"^0.11.1\"}],\"features\":{\"alloc\":[\"serde?/alloc\"],\"databake\":[\"dep:databake\"],\"derive\":[\"dep:zerovec-derive\"],\"hashmap\":[\"dep:twox-hash\",\"alloc\"],\"serde\":[\"dep:serde\"],\"std\":[],\"yoke\":[\"dep:yoke\"]}}", "zip_2.4.2": "{\"dependencies\":[{\"name\":\"aes\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.95\"},{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"req\":\"^1.4.1\",\"target\":\"cfg(fuzzing)\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"bzip2\",\"optional\":true,\"req\":\"^0.5.0\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"=4.4.18\"},{\"name\":\"constant_time_eq\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"crc32fast\",\"req\":\"^1.4\"},{\"name\":\"crossbeam-utils\",\"req\":\"^0.8.21\",\"target\":\"cfg(any(all(target_arch = \\\"arm\\\", target_pointer_width = \\\"32\\\"), target_arch = \\\"mips\\\", target_arch = \\\"powerpc\\\"))\"},{\"name\":\"deflate64\",\"optional\":true,\"req\":\"^0.1.9\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"wasm_js\",\"std\"],\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.1\"},{\"features\":[\"wasm_js\",\"std\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3.1\"},{\"features\":[\"reset\"],\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"indexmap\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"lzma-rs\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"memchr\",\"req\":\"^2.7\"},{\"default_features\":false,\"name\":\"nt-time\",\"optional\":true,\"req\":\"^0.10.6\"},{\"name\":\"pbkdf2\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.15\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.37\"},{\"default_features\":false,\"features\":[\"formatting\",\"macros\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.37\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5\"},{\"name\":\"xz2\",\"optional\":true,\"req\":\"^0.1.7\"},{\"features\":[\"zeroize_derive\"],\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.8\"},{\"name\":\"zopfli\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"zstd\",\"optional\":true,\"req\":\"^0.13\"}],\"features\":{\"_all-features\":[],\"_deflate-any\":[],\"aes-crypto\":[\"aes\",\"constant_time_eq\",\"hmac\",\"pbkdf2\",\"sha1\",\"getrandom\",\"zeroize\"],\"chrono\":[\"chrono/default\"],\"default\":[\"aes-crypto\",\"bzip2\",\"deflate64\",\"deflate\",\"lzma\",\"time\",\"zstd\",\"xz\"],\"deflate\":[\"flate2/rust_backend\",\"deflate-zopfli\",\"deflate-flate2\"],\"deflate-flate2\":[\"_deflate-any\"],\"deflate-miniz\":[\"deflate\",\"deflate-flate2\"],\"deflate-zlib\":[\"flate2/zlib\",\"deflate-flate2\"],\"deflate-zlib-ng\":[\"flate2/zlib-ng\",\"deflate-flate2\"],\"deflate-zopfli\":[\"zopfli\",\"_deflate-any\"],\"lzma\":[\"lzma-rs/stream\"],\"nt-time\":[\"dep:nt-time\"],\"unreserved\":[],\"xz\":[\"dep:xz2\"]}}", "zmij_1.0.19": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1.36\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-integer\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"opt-level\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"ryu\",\"req\":\"^1\"}],\"features\":{}}", + "zmij_1.0.21": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1.36\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-integer\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"opt-level\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"ryu\",\"req\":\"^1\"}],\"features\":{}}", "zoneinfo64_0.2.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"calendrical_calculations\",\"req\":\"^0.2.3\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"chrono-tz\",\"req\":\"^0.10.4\"},{\"default_features\":false,\"name\":\"icu_locale_core\",\"req\":\"^2.1.0\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"features\":[\"tzdb-bundle-always\",\"std\"],\"kind\":\"dev\",\"name\":\"jiff\",\"req\":\"^0.2.15\"},{\"default_features\":false,\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"resb\",\"req\":\"^0.1.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.220\"}],\"features\":{\"chrono\":[\"dep:chrono\"]}}", "zopfli_0.8.3": "{\"dependencies\":[{\"name\":\"bumpalo\",\"req\":\"^3.19.0\"},{\"default_features\":false,\"name\":\"crc32fast\",\"optional\":true,\"req\":\"^1.5.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.28\"},{\"kind\":\"dev\",\"name\":\"miniz_oxide\",\"req\":\"^0.8.9\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.7.0\"},{\"kind\":\"dev\",\"name\":\"proptest-derive\",\"req\":\"^0.6.0\"},{\"default_features\":false,\"name\":\"simd-adler32\",\"optional\":true,\"req\":\"^0.3.7\"}],\"features\":{\"default\":[\"gzip\",\"std\",\"zlib\"],\"gzip\":[\"dep:crc32fast\"],\"nightly\":[\"crc32fast?/nightly\"],\"std\":[\"crc32fast?/std\",\"dep:log\",\"simd-adler32?/std\"],\"zlib\":[\"dep:simd-adler32\"]}}", "zstd-safe_7.2.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"zstd-sys\",\"req\":\"^2.0.15\"}],\"features\":{\"arrays\":[],\"bindgen\":[\"zstd-sys/bindgen\"],\"debug\":[\"zstd-sys/debug\"],\"default\":[\"legacy\",\"arrays\",\"zdict_builder\"],\"doc-cfg\":[],\"experimental\":[\"zstd-sys/experimental\"],\"fat-lto\":[\"zstd-sys/fat-lto\"],\"legacy\":[\"zstd-sys/legacy\"],\"no_asm\":[\"zstd-sys/no_asm\"],\"pkg-config\":[\"zstd-sys/pkg-config\"],\"seekable\":[\"zstd-sys/seekable\"],\"std\":[\"zstd-sys/std\"],\"thin\":[\"zstd-sys/thin\"],\"thin-lto\":[\"zstd-sys/thin-lto\"],\"zdict_builder\":[\"zstd-sys/zdict_builder\"],\"zstdmt\":[\"zstd-sys/zstdmt\"]}}", @@ -1583,15 +1641,19 @@ }, "@@rules_rs+//rs/experimental/toolchains:module_extension.bzl%toolchains": { "cargo-1.93.0-aarch64-apple-darwin.tar.xz": "6443909350322ad07f09bb5edfd9ff29268e6fe88c7d78bfba7a5e254248dc25", + "cargo-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "387832b989c8eb96c9ebd66402a87962167633bc6d91a49ffb8c7903c45f3476", "cargo-1.93.0-aarch64-pc-windows-msvc.tar.xz": "155bff7a16aa7054e7ed7c3a82e362d4b302b3882d751b823e06ff63ae3f103d", "cargo-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "5998940b8b97286bb67facb1a85535eeb3d4d7a61e36a85e386e5c0c5cfe5266", "cargo-1.93.0-x86_64-apple-darwin.tar.xz": "95a47c5ed797c35419908f04188d8b7de09946e71073c4b72632b16f5b10dfae", + "cargo-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "f19766837559f90476508140cb95cc708220012ec00a854fa9f99187b1f246b6", "cargo-1.93.0-x86_64-pc-windows-msvc.tar.xz": "e59c5e2baa9ec17261f2cda6676ebf7b68b21a860e3f7451c4d964728951da75", "cargo-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "c23de3ae709ff33eed5e4ae59d1f9bcd75fa4dbaa9fb92f7b06bfb534b8db880", "clippy-1.93.0-aarch64-apple-darwin.tar.xz": "0b6e943a8d12be0e68575acf59c9ea102daf795055fcbbf862b0bfd35ec40039", + "clippy-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "296949f49be2de77ce9d1c5023a5f0a58e28e329eec03642a3da0e175a67beeb", "clippy-1.93.0-aarch64-pc-windows-msvc.tar.xz": "07bcf2edb88cdf5ead2f02e4a8493e9b0ef935a31253fac6f9f3378d8023f113", "clippy-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "872ae6d68d625946d281b91d928332e6b74f6ab269b6af842338df4338805a60", "clippy-1.93.0-x86_64-apple-darwin.tar.xz": "e6d0b1afb9607c14a1172d09ee194a032bbb3e48af913d55c5a473e0559eddde", + "clippy-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "b6f1f7264ed6943c59dedfb9531fbadcc3c0fcf273c940a63d58898b14a1060f", "clippy-1.93.0-x86_64-pc-windows-msvc.tar.xz": "25fb103390bf392980b4689ac09b2ec2ab4beefb7022a983215b613ad05eab57", "clippy-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "793108977514b15c0f45ade28ae35c58b05370cb0f22e89bd98fdfa61eabf55d", "rust-std-1.93.0-aarch64-apple-darwin.tar.xz": "8603c63715349636ed85b4fe716c4e827a727918c840e54aff5b243cedadf19b", @@ -1660,15 +1722,19 @@ "rust-std-1.93.0-x86_64-unknown-none.tar.xz": "01dcca7ae4b7e82fbfa399adb5e160afaa13143e5a17e1e0737c38cf07365fb3", "rust-std-1.93.0-x86_64-unknown-uefi.tar.xz": "ec4e439d9485ce752b56999e8e41ed82373fc833a005cf2531c6f7ef7e785392", "rustc-1.93.0-aarch64-apple-darwin.tar.xz": "092be03c02b44c405dab1232541c84f32b2d9e8295747568c3d531dd137221dc", + "rustc-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "d3bc0cdaf157e20b1f23e510b5e3c4c6e9117d08f5284c04dee60aecff1bc851", "rustc-1.93.0-aarch64-pc-windows-msvc.tar.xz": "a3ac1a8e411de8470f71b366f89d187718c431526912b181692ed0a18c56c7ad", "rustc-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "1a9045695892ec08d8e9751bf7cf7db71fe27a6202dd12ce13aca48d0602dbde", "rustc-1.93.0-x86_64-apple-darwin.tar.xz": "594bb293f0a4f444656cf8dec2149fcb979c606260efee9e09bcf8c9c6ed6ae7", + "rustc-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "0cdaa8de66f5ce21d1ea73917efc5c64f408bda49f678ddde19465ced9d5ec63", "rustc-1.93.0-x86_64-pc-windows-msvc.tar.xz": "fa17677eee0d83eb055b309953184bf87ba634923d8897f860cda65d55c6e350", "rustc-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "00c6e6740ea6a795e33568cd7514855d58408a1180cd820284a7bbf7c46af715", "rustfmt-1.93.0-aarch64-apple-darwin.tar.xz": "0dd1faedf0768ef362f4aae4424b34e8266f2b9cf5e76ea4fcaf780220b363a0", + "rustfmt-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "5888827e7fbd7d59930870b4856fce8d6d8fca5e02f6535f8ae3d7ad0ccf2d4a", "rustfmt-1.93.0-aarch64-pc-windows-msvc.tar.xz": "24eed108489567133bbfe40c8eacda1567be55fae4c526911b39eb33eb27a6cb", "rustfmt-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "92e1acb45ae642136258b4dabb39302af2d53c83e56ebd5858bc969f9e5c141a", "rustfmt-1.93.0-x86_64-apple-darwin.tar.xz": "c8453b4c5758eb39423042ffa9c23ed6128cbed2b15b581e5e1192c9cc0b1d4e", + "rustfmt-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "47167e9e78db9be4503a060dee02f4df2cda252da32175dbf44331f965a747b9", "rustfmt-1.93.0-x86_64-pc-windows-msvc.tar.xz": "5becc7c2dba4b9ab5199012cad30829235a7f7fb5d85a238697e8f0e44cbd9af", "rustfmt-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "7f81f6c17d11a7fda5b4e1b111942fb3b23d30dcec767e13e340ebfb762a5e33" } diff --git a/README.md b/README.md index 1e44875f21..5cc7fd4953 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Each archive contains a single entry with the platform baked into the name (e.g. ### Using Codex with your ChatGPT plan -Run `codex` and select **Sign in with ChatGPT**. We recommend signing into your ChatGPT account to use Codex as part of your Plus, Pro, Team, Edu, or Enterprise plan. [Learn more about what's included in your ChatGPT plan](https://help.openai.com/en/articles/11369540-codex-in-chatgpt). +Run `codex` and select **Sign in with ChatGPT**. We recommend signing into your ChatGPT account to use Codex as part of your Plus, Pro, Business, Edu, or Enterprise plan. [Learn more about what's included in your ChatGPT plan](https://help.openai.com/en/articles/11369540-codex-in-chatgpt). You can also use Codex with an API key, but this requires [additional setup](https://developers.openai.com/codex/auth#sign-in-with-an-api-key). diff --git a/codex-rs/BUILD.bazel b/codex-rs/BUILD.bazel index 47324dbdca..66c7ebcb61 100644 --- a/codex-rs/BUILD.bazel +++ b/codex-rs/BUILD.bazel @@ -1,3 +1,18 @@ exports_files([ + "clippy.toml", "node-version.txt", ]) + +filegroup( + name = "workspace-files", + srcs = glob( + [ + "*", + ".cargo/**", + ], + exclude = [ + "BUILD.bazel", + ], + ), + visibility = ["//visibility:public"], +) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index fb1710987e..b109d64430 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -408,8 +408,11 @@ dependencies = [ "base64 0.22.1", "chrono", "codex-app-server-protocol", + "codex-config", "codex-core", "codex-features", + "codex-login", + "codex-models-manager", "codex-protocol", "codex-utils-cargo-bin", "core_test_support", @@ -446,16 +449,16 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.52.0", "wl-clipboard-rs", "x11rb", ] [[package]] name = "arc-swap" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" dependencies = [ "rustversion", ] @@ -481,58 +484,6 @@ dependencies = [ "term", ] -[[package]] -name = "askama" -version = "0.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08e1676b346cadfec169374f949d7490fd80a24193d37d2afce0c047cf695e57" -dependencies = [ - "askama_macros", - "itoa", - "percent-encoding", - "serde", - "serde_json", -] - -[[package]] -name = "askama_derive" -version = "0.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7661ff56517787343f376f75db037426facd7c8d3049cef8911f1e75016f3a37" -dependencies = [ - "askama_parser", - "basic-toml", - "memchr", - "proc-macro2", - "quote", - "rustc-hash 2.1.1", - "serde", - "serde_derive", - "syn 2.0.114", -] - -[[package]] -name = "askama_macros" -version = "0.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "713ee4dbfd1eb719c2dab859465b01fa1d21cb566684614a713a6b7a99a4e47b" -dependencies = [ - "askama_derive", -] - -[[package]] -name = "askama_parser" -version = "0.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d62d674238a526418b30c0def480d5beadb9d8964e7f38d635b03bf639c704c" -dependencies = [ - "rustc-hash 2.1.1", - "serde", - "serde_derive", - "unicode-ident", - "winnow", -] - [[package]] name = "asn1-rs" version = "0.7.1" @@ -1388,10 +1339,12 @@ checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21" name = "codex-analytics" version = "0.0.0" dependencies = [ + "codex-app-server-protocol", "codex-git-utils", "codex-login", "codex-plugin", "codex-protocol", + "os_info", "pretty_assertions", "serde", "serde_json", @@ -1416,7 +1369,9 @@ dependencies = [ "anyhow", "assert_matches", "async-trait", + "base64 0.22.1", "bytes", + "chrono", "codex-client", "codex-protocol", "codex-utils-rustls-provider", @@ -1450,11 +1405,13 @@ dependencies = [ "base64 0.22.1", "chrono", "clap", + "codex-analytics", "codex-app-server-protocol", "codex-arg0", "codex-backend-client", "codex-chatgpt", "codex-cloud-requirements", + "codex-config", "codex-core", "codex-exec-server", "codex-features", @@ -1462,20 +1419,26 @@ dependencies = [ "codex-file-search", "codex-git-utils", "codex-login", + "codex-mcp", + "codex-models-manager", "codex-otel", "codex-protocol", "codex-rmcp-client", + "codex-rollout", "codex-sandboxing", "codex-shell-command", "codex-state", + "codex-tools", "codex-utils-absolute-path", "codex-utils-cargo-bin", "codex-utils-cli", "codex-utils-json-to-toml", "codex-utils-pty", + "codex-utils-rustls-provider", "constant_time_eq", "core_test_support", "futures", + "gethostname", "hmac", "jsonwebtoken", "opentelemetry", @@ -1498,6 +1461,7 @@ dependencies = [ "tracing", "tracing-opentelemetry", "tracing-subscriber", + "url", "uuid", "wiremock", ] @@ -1532,6 +1496,7 @@ dependencies = [ "codex-experimental-api-macros", "codex-git-utils", "codex-protocol", + "codex-shell-command", "codex-utils-absolute-path", "codex-utils-cargo-bin", "inventory", @@ -1541,7 +1506,6 @@ dependencies = [ "serde", "serde_json", "serde_with", - "shlex", "similar", "strum_macros 0.28.0", "tempfile", @@ -1579,11 +1543,14 @@ dependencies = [ "anyhow", "assert_cmd", "assert_matches", + "codex-exec-server", + "codex-utils-absolute-path", "codex-utils-cargo-bin", "pretty_assertions", "similar", "tempfile", "thiserror 2.0.18", + "tokio", "tree-sitter", "tree-sitter-bash", ] @@ -1594,35 +1561,17 @@ version = "0.0.0" dependencies = [ "anyhow", "codex-apply-patch", + "codex-exec-server", "codex-linux-sandbox", + "codex-sandboxing", "codex-shell-escalation", + "codex-utils-absolute-path", "codex-utils-home-dir", "dotenvy", "tempfile", "tokio", ] -[[package]] -name = "codex-artifacts" -version = "0.0.0" -dependencies = [ - "codex-package-manager", - "flate2", - "pretty_assertions", - "reqwest", - "serde", - "serde_json", - "sha2", - "tar", - "tempfile", - "thiserror 2.0.18", - "tokio", - "url", - "which 8.0.0", - "wiremock", - "zip", -] - [[package]] name = "codex-async-utils" version = "0.0.0" @@ -1640,7 +1589,7 @@ dependencies = [ "anyhow", "codex-backend-openapi-models", "codex-client", - "codex-core", + "codex-login", "codex-protocol", "pretty_assertions", "reqwest", @@ -1663,9 +1612,11 @@ version = "0.0.0" dependencies = [ "anyhow", "clap", + "codex-config", "codex-connectors", "codex-core", "codex-git-utils", + "codex-login", "codex-utils-cargo-bin", "codex-utils-cli", "pretty_assertions", @@ -1696,6 +1647,7 @@ dependencies = [ "codex-execpolicy", "codex-features", "codex-login", + "codex-mcp", "codex-mcp-server", "codex-protocol", "codex-responses-api-proxy", @@ -1705,9 +1657,9 @@ dependencies = [ "codex-stdio-to-uds", "codex-terminal-detection", "codex-tui", - "codex-tui-app-server", "codex-utils-cargo-bin", "codex-utils-cli", + "codex-utils-path", "codex-windows-sandbox", "libc", "owo-colors", @@ -1763,7 +1715,9 @@ dependencies = [ "base64 0.22.1", "chrono", "codex-backend-client", + "codex-config", "codex-core", + "codex-login", "codex-otel", "codex-protocol", "hmac", @@ -1789,6 +1743,7 @@ dependencies = [ "clap", "codex-client", "codex-cloud-tasks-client", + "codex-cloud-tasks-mock-client", "codex-core", "codex-git-utils", "codex-login", @@ -1818,12 +1773,21 @@ dependencies = [ "chrono", "codex-backend-client", "codex-git-utils", - "diffy", "serde", "serde_json", "thiserror 2.0.18", ] +[[package]] +name = "codex-cloud-tasks-mock-client" +version = "0.0.0" +dependencies = [ + "async-trait", + "chrono", + "codex-cloud-tasks-client", + "diffy", +] + [[package]] name = "codex-code-mode" version = "0.0.0" @@ -1838,6 +1802,10 @@ dependencies = [ "v8", ] +[[package]] +name = "codex-collaboration-mode-templates" +version = "0.0.0" + [[package]] name = "codex-config" version = "0.0.0" @@ -1845,8 +1813,13 @@ dependencies = [ "anyhow", "codex-app-server-protocol", "codex-execpolicy", + "codex-features", + "codex-git-utils", + "codex-model-provider-info", + "codex-network-proxy", "codex-protocol", "codex-utils-absolute-path", + "dunce", "futures", "multimap", "pretty_assertions", @@ -1855,11 +1828,13 @@ dependencies = [ "serde_json", "serde_path_to_error", "sha2", + "tempfile", "thiserror 2.0.18", "tokio", "toml 0.9.11+spec-1.1.0", "toml_edit 0.24.0+spec-1.1.0", "tracing", + "wildmatch", ] [[package]] @@ -1880,14 +1855,12 @@ version = "0.0.0" dependencies = [ "anyhow", "arc-swap", - "askama", "assert_cmd", "assert_matches", "async-channel", "async-trait", "base64 0.22.1", "bm25", - "chardetng", "chrono", "clap", "codex-analytics", @@ -1895,7 +1868,6 @@ dependencies = [ "codex-app-server-protocol", "codex-apply-patch", "codex-arg0", - "codex-artifacts", "codex-async-utils", "codex-code-mode", "codex-config", @@ -1904,14 +1876,19 @@ dependencies = [ "codex-exec-server", "codex-execpolicy", "codex-features", + "codex-feedback", "codex-git-utils", "codex-hooks", "codex-instructions", "codex-login", + "codex-mcp", + "codex-model-provider-info", + "codex-models-manager", "codex-network-proxy", "codex-otel", "codex-plugin", "codex-protocol", + "codex-response-debug-context", "codex-rmcp-client", "codex-rollout", "codex-sandboxing", @@ -1920,7 +1897,7 @@ dependencies = [ "codex-shell-escalation", "codex-state", "codex-terminal-detection", - "codex-test-macros", + "codex-tools", "codex-utils-absolute-path", "codex-utils-cache", "codex-utils-cargo-bin", @@ -1933,6 +1910,7 @@ dependencies = [ "codex-utils-readiness", "codex-utils-stream-parser", "codex-utils-string", + "codex-utils-template", "codex-windows-sandbox", "core-foundation 0.9.4", "core_test_support", @@ -1940,7 +1918,6 @@ dependencies = [ "ctor 0.6.3", "dirs", "dunce", - "encoding_rs", "env-flags", "eventsource-stream", "futures", @@ -1949,7 +1926,6 @@ dependencies = [ "image", "indexmap 2.13.0", "insta", - "landlock", "libc", "maplit", "notify", @@ -1963,8 +1939,6 @@ dependencies = [ "regex-lite", "reqwest", "rmcp", - "schemars 0.8.22", - "seccompiler", "serde", "serde_json", "serial_test", @@ -1988,7 +1962,6 @@ dependencies = [ "uuid", "walkdir", "which 8.0.0", - "wildmatch", "windows-sys 0.52.0", "wiremock", "zip", @@ -2051,6 +2024,8 @@ dependencies = [ "codex-core", "codex-feedback", "codex-git-utils", + "codex-login", + "codex-model-provider-info", "codex-otel", "codex-protocol", "codex-utils-absolute-path", @@ -2083,6 +2058,7 @@ name = "codex-exec-server" version = "0.0.0" dependencies = [ "anyhow", + "arc-swap", "async-trait", "base64 0.22.1", "clap", @@ -2152,7 +2128,6 @@ dependencies = [ name = "codex-features" version = "0.0.0" dependencies = [ - "codex-login", "codex-otel", "codex-protocol", "pretty_assertions", @@ -2167,6 +2142,7 @@ name = "codex-feedback" version = "0.0.0" dependencies = [ "anyhow", + "codex-login", "codex-protocol", "pretty_assertions", "sentry", @@ -2250,8 +2226,10 @@ version = "0.0.0" dependencies = [ "cc", "clap", + "codex-config", "codex-core", "codex-protocol", + "codex-sandboxing", "codex-utils-absolute-path", "landlock", "libc", @@ -2270,6 +2248,7 @@ name = "codex-lmstudio" version = "0.0.0" dependencies = [ "codex-core", + "codex-model-provider-info", "reqwest", "serde_json", "tokio", @@ -2286,12 +2265,16 @@ dependencies = [ "async-trait", "base64 0.22.1", "chrono", + "codex-api", "codex-app-server-protocol", "codex-client", "codex-config", "codex-keyring-store", + "codex-model-provider-info", + "codex-otel", "codex-protocol", "codex-terminal-detection", + "codex-utils-template", "core_test_support", "keyring", "once_cell", @@ -2300,7 +2283,6 @@ dependencies = [ "rand 0.9.2", "regex-lite", "reqwest", - "schemars 0.8.22", "serde", "serde_json", "serial_test", @@ -2316,14 +2298,47 @@ dependencies = [ "wiremock", ] +[[package]] +name = "codex-mcp" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-channel", + "codex-async-utils", + "codex-config", + "codex-login", + "codex-otel", + "codex-plugin", + "codex-protocol", + "codex-rmcp-client", + "codex-utils-plugins", + "futures", + "pretty_assertions", + "regex-lite", + "rmcp", + "serde", + "serde_json", + "sha1", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "url", +] + [[package]] name = "codex-mcp-server" version = "0.0.0" dependencies = [ "anyhow", "codex-arg0", + "codex-config", "codex-core", + "codex-exec-server", "codex-features", + "codex-login", + "codex-models-manager", "codex-protocol", "codex-shell-command", "codex-utils-cli", @@ -2344,6 +2359,54 @@ dependencies = [ "wiremock", ] +[[package]] +name = "codex-model-provider-info" +version = "0.0.0" +dependencies = [ + "codex-api", + "codex-app-server-protocol", + "codex-protocol", + "codex-utils-absolute-path", + "http 1.4.0", + "maplit", + "pretty_assertions", + "schemars 0.8.22", + "serde", + "tempfile", + "toml 0.9.11+spec-1.1.0", +] + +[[package]] +name = "codex-models-manager" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "chrono", + "codex-api", + "codex-app-server-protocol", + "codex-collaboration-mode-templates", + "codex-config", + "codex-feedback", + "codex-login", + "codex-model-provider-info", + "codex-otel", + "codex-protocol", + "codex-response-debug-context", + "codex-utils-absolute-path", + "codex-utils-output-truncation", + "codex-utils-template", + "core_test_support", + "http 1.4.0", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber", + "wiremock", +] + [[package]] name = "codex-network-proxy" version = "0.0.0" @@ -2383,6 +2446,7 @@ dependencies = [ "async-stream", "bytes", "codex-core", + "codex-model-provider-info", "futures", "pretty_assertions", "reqwest", @@ -2425,26 +2489,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "codex-package-manager" -version = "0.0.0" -dependencies = [ - "fd-lock", - "flate2", - "pretty_assertions", - "reqwest", - "serde", - "serde_json", - "sha2", - "tar", - "tempfile", - "thiserror 2.0.18", - "tokio", - "url", - "wiremock", - "zip", -] - [[package]] name = "codex-plugin" version = "0.0.0" @@ -2467,17 +2511,27 @@ name = "codex-protocol" version = "0.0.0" dependencies = [ "anyhow", + "chardetng", + "chrono", + "codex-async-utils", "codex-execpolicy", "codex-git-utils", + "codex-network-proxy", "codex-utils-absolute-path", "codex-utils-image", "codex-utils-string", + "codex-utils-template", + "encoding_rs", + "http 1.4.0", "icu_decimal", "icu_locale_core", "icu_provider", + "landlock", "pretty_assertions", "quick-xml", + "reqwest", "schemars 0.8.22", + "seccompiler", "serde", "serde_json", "serde_with", @@ -2485,11 +2539,24 @@ dependencies = [ "strum_macros 0.28.0", "sys-locale", "tempfile", + "thiserror 2.0.18", + "tokio", "tracing", "ts-rs", "uuid", ] +[[package]] +name = "codex-response-debug-context" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "codex-api", + "http 1.4.0", + "pretty_assertions", + "serde_json", +] + [[package]] name = "codex-responses-api-proxy" version = "0.0.0" @@ -2499,6 +2566,7 @@ dependencies = [ "codex-process-hardening", "ctor 0.6.3", "libc", + "pretty_assertions", "reqwest", "serde", "serde_json", @@ -2513,6 +2581,7 @@ dependencies = [ "anyhow", "axum", "codex-client", + "codex-config", "codex-keyring-store", "codex-protocol", "codex-utils-cargo-bin", @@ -2524,7 +2593,6 @@ dependencies = [ "pretty_assertions", "reqwest", "rmcp", - "schemars 0.8.22", "serde", "serde_json", "serial_test", @@ -2572,7 +2640,6 @@ dependencies = [ "codex-network-proxy", "codex-protocol", "codex-utils-absolute-path", - "dirs", "dunce", "libc", "pretty_assertions", @@ -2580,6 +2647,7 @@ dependencies = [ "tempfile", "tracing", "url", + "which 8.0.0", ] [[package]] @@ -2696,12 +2764,20 @@ dependencies = [ ] [[package]] -name = "codex-test-macros" +name = "codex-tools" version = "0.0.0" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", + "codex-app-server-protocol", + "codex-code-mode", + "codex-features", + "codex-protocol", + "codex-utils-absolute-path", + "codex-utils-pty", + "pretty_assertions", + "rmcp", + "serde", + "serde_json", + "tracing", ] [[package]] @@ -2718,23 +2794,26 @@ dependencies = [ "codex-app-server-client", "codex-app-server-protocol", "codex-arg0", - "codex-backend-client", "codex-chatgpt", "codex-cli", - "codex-client", "codex-cloud-requirements", + "codex-config", "codex-core", + "codex-exec-server", "codex-features", "codex-feedback", "codex-file-search", "codex-git-utils", "codex-login", + "codex-mcp", + "codex-model-provider-info", + "codex-models-manager", "codex-otel", "codex-protocol", + "codex-rollout", "codex-shell-command", "codex-state", "codex-terminal-detection", - "codex-tui-app-server", "codex-utils-absolute-path", "codex-utils-approval-presets", "codex-utils-cargo-bin", @@ -2754,100 +2833,6 @@ dependencies = [ "diffy", "dirs", "dunce", - "hound", - "image", - "insta", - "itertools 0.14.0", - "lazy_static", - "libc", - "pathdiff", - "pretty_assertions", - "pulldown-cmark", - "rand 0.9.2", - "ratatui", - "ratatui-macros", - "regex-lite", - "reqwest", - "rmcp", - "serde", - "serde_json", - "serial_test", - "shlex", - "strum 0.27.2", - "strum_macros 0.28.0", - "supports-color 3.0.2", - "syntect", - "tempfile", - "textwrap 0.16.2", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tokio-util", - "toml 0.9.11+spec-1.1.0", - "tracing", - "tracing-appender", - "tracing-subscriber", - "two-face", - "unicode-segmentation", - "unicode-width 0.2.1", - "url", - "uuid", - "vt100", - "webbrowser", - "which 8.0.0", - "windows-sys 0.52.0", - "winsplit", -] - -[[package]] -name = "codex-tui-app-server" -version = "0.0.0" -dependencies = [ - "anyhow", - "arboard", - "assert_matches", - "base64 0.22.1", - "chrono", - "clap", - "codex-ansi-escape", - "codex-app-server-client", - "codex-app-server-protocol", - "codex-arg0", - "codex-chatgpt", - "codex-cli", - "codex-client", - "codex-cloud-requirements", - "codex-core", - "codex-features", - "codex-feedback", - "codex-file-search", - "codex-git-utils", - "codex-login", - "codex-otel", - "codex-protocol", - "codex-shell-command", - "codex-state", - "codex-terminal-detection", - "codex-utils-absolute-path", - "codex-utils-approval-presets", - "codex-utils-cargo-bin", - "codex-utils-cli", - "codex-utils-elapsed", - "codex-utils-fuzzy-match", - "codex-utils-oss", - "codex-utils-pty", - "codex-utils-sandbox-summary", - "codex-utils-sleep-inhibitor", - "codex-utils-string", - "codex-windows-sandbox", - "color-eyre", - "cpal", - "crossterm", - "derive_more 2.1.1", - "diffy", - "dirs", - "dunce", - "hound", "image", "insta", "itertools 0.14.0", @@ -2884,6 +2869,7 @@ dependencies = [ "unicode-segmentation", "unicode-width 0.2.1", "url", + "urlencoding", "uuid", "vt100", "webbrowser", @@ -2897,7 +2883,6 @@ name = "codex-utils-absolute-path" version = "0.0.0" dependencies = [ "dirs", - "path-absolutize", "pretty_assertions", "schemars 0.8.22", "serde", @@ -2986,6 +2971,7 @@ version = "0.0.0" dependencies = [ "codex-core", "codex-lmstudio", + "codex-model-provider-info", "codex-ollama", ] @@ -3012,6 +2998,7 @@ dependencies = [ name = "codex-utils-plugins" version = "0.0.0" dependencies = [ + "codex-login", "serde", "serde_json", "tempfile", @@ -3056,6 +3043,7 @@ name = "codex-utils-sandbox-summary" version = "0.0.0" dependencies = [ "codex-core", + "codex-model-provider-info", "codex-protocol", "codex-utils-absolute-path", "pretty_assertions", @@ -3326,6 +3314,9 @@ dependencies = [ "codex-core", "codex-exec-server", "codex-features", + "codex-login", + "codex-model-provider-info", + "codex-models-manager", "codex-protocol", "codex-utils-absolute-path", "codex-utils-cargo-bin", @@ -3980,7 +3971,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4225,7 +4216,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4353,17 +4344,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "filetime" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" -dependencies = [ - "cfg-if", - "libc", - "libredox", -] - [[package]] name = "find-crate" version = "0.6.3" @@ -5008,12 +4988,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "hound" -version = "3.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" - [[package]] name = "http" version = "0.2.12" @@ -5148,14 +5122,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", - "futures-core", "futures-util", "http 1.4.0", "http-body", @@ -5164,7 +5137,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -5670,7 +5643,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6157,7 +6130,7 @@ name = "mcp_test_support" version = "0.0.0" dependencies = [ "anyhow", - "codex-core", + "codex-login", "codex-mcp-server", "codex-terminal-detection", "codex-utils-cargo-bin", @@ -6448,7 +6421,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6624,7 +6597,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -7092,7 +7065,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] @@ -7638,7 +7611,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.6.2", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -7675,9 +7648,9 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2 0.6.2", + "socket2 0.5.10", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -8280,7 +8253,6 @@ dependencies = [ "js-sys", "log", "mime", - "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", @@ -8514,7 +8486,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -9913,9 +9885,9 @@ dependencies = [ [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags 2.10.0", "core-foundation 0.9.4", @@ -9938,17 +9910,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" -[[package]] -name = "tar" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" -dependencies = [ - "filetime", - "libc", - "xattr", -] - [[package]] name = "tempfile" version = "3.24.0" @@ -9959,7 +9920,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -11405,7 +11366,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -12075,16 +12036,6 @@ dependencies = [ "time", ] -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix 1.1.3", -] - [[package]] name = "xdg-home" version = "1.3.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index f19fb650f7..16329276cd 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -18,7 +18,9 @@ members = [ "cloud-requirements", "cloud-tasks", "cloud-tasks-client", + "cloud-tasks-mock-client", "cli", + "collaboration-mode-templates", "connectors", "config", "shell-command", @@ -38,7 +40,10 @@ members = [ "linux-sandbox", "lmstudio", "login", + "codex-mcp", "mcp-server", + "model-provider-info", + "models-manager", "network-proxy", "ollama", "process-hardening", @@ -46,11 +51,12 @@ members = [ "rollout", "rmcp-client", "responses-api-proxy", + "response-debug-context", "sandboxing", "stdio-to-uds", "otel", "tui", - "tui_app_server", + "tools", "v8-poc", "utils/absolute-path", "utils/cargo-bin", @@ -80,10 +86,7 @@ members = [ "state", "terminal-detection", "codex-experimental-api-macros", - "test-macros", - "package-manager", "plugin", - "artifacts", ] resolver = "2" @@ -99,12 +102,9 @@ license = "Apache-2.0" [workspace.dependencies] # Internal app_test_support = { path = "app-server/tests/common" } -codex-ansi-escape = { path = "ansi-escape" } codex-analytics = { path = "analytics" } +codex-ansi-escape = { path = "ansi-escape" } codex-api = { path = "codex-api" } -codex-artifacts = { path = "artifacts" } -codex-code-mode = { path = "code-mode" } -codex-package-manager = { path = "package-manager" } codex-app-server = { path = "app-server" } codex-app-server-client = { path = "app-server-client" } codex-app-server-protocol = { path = "app-server-protocol" } @@ -116,17 +116,21 @@ codex-backend-client = { path = "backend-client" } codex-chatgpt = { path = "chatgpt" } codex-cli = { path = "cli" } codex-client = { path = "codex-client" } +codex-collaboration-mode-templates = { path = "collaboration-mode-templates" } codex-cloud-requirements = { path = "cloud-requirements" } -codex-connectors = { path = "connectors" } +codex-cloud-tasks-client = { path = "cloud-tasks-client" } +codex-cloud-tasks-mock-client = { path = "cloud-tasks-mock-client" } +codex-code-mode = { path = "code-mode" } codex-config = { path = "config" } +codex-connectors = { path = "connectors" } codex-core = { path = "core" } codex-core-skills = { path = "core-skills" } codex-exec = { path = "exec" } codex-exec-server = { path = "exec-server" } codex-execpolicy = { path = "execpolicy" } codex-experimental-api-macros = { path = "codex-experimental-api-macros" } -codex-feedback = { path = "feedback" } codex-features = { path = "features" } +codex-feedback = { path = "feedback" } codex-file-search = { path = "file-search" } codex-git-utils = { path = "git-utils" } codex-hooks = { path = "hooks" } @@ -135,16 +139,20 @@ codex-keyring-store = { path = "keyring-store" } codex-linux-sandbox = { path = "linux-sandbox" } codex-lmstudio = { path = "lmstudio" } codex-login = { path = "login" } +codex-mcp = { path = "codex-mcp" } codex-mcp-server = { path = "mcp-server" } +codex-model-provider-info = { path = "model-provider-info" } +codex-models-manager = { path = "models-manager" } codex-network-proxy = { path = "network-proxy" } codex-ollama = { path = "ollama" } codex-otel = { path = "otel" } codex-plugin = { path = "plugin" } codex-process-hardening = { path = "process-hardening" } codex-protocol = { path = "protocol" } -codex-rollout = { path = "rollout" } codex-responses-api-proxy = { path = "responses-api-proxy" } +codex-response-debug-context = { path = "response-debug-context" } codex-rmcp-client = { path = "rmcp-client" } +codex-rollout = { path = "rollout" } codex-sandboxing = { path = "sandboxing" } codex-secrets = { path = "secrets" } codex-shell-command = { path = "shell-command" } @@ -152,11 +160,9 @@ codex-shell-escalation = { path = "shell-escalation" } codex-skills = { path = "skills" } codex-state = { path = "state" } codex-stdio-to-uds = { path = "stdio-to-uds" } -codex-test-macros = { path = "test-macros" } codex-terminal-detection = { path = "terminal-detection" } +codex-tools = { path = "tools" } codex-tui = { path = "tui" } -codex-tui-app-server = { path = "tui_app_server" } -codex-v8-poc = { path = "v8-poc" } codex-utils-absolute-path = { path = "utils/absolute-path" } codex-utils-approval-presets = { path = "utils/approval-presets" } codex-utils-cache = { path = "utils/cache" } @@ -177,8 +183,9 @@ codex-utils-rustls-provider = { path = "utils/rustls-provider" } codex-utils-sandbox-summary = { path = "utils/sandbox-summary" } codex-utils-sleep-inhibitor = { path = "utils/sleep-inhibitor" } codex-utils-stream-parser = { path = "utils/stream-parser" } -codex-utils-template = { path = "utils/template" } codex-utils-string = { path = "utils/string" } +codex-utils-template = { path = "utils/template" } +codex-v8-poc = { path = "v8-poc" } codex-windows-sandbox = { path = "windows-sandbox-rs" } core_test_support = { path = "core/tests/common" } mcp_test_support = { path = "mcp-server/tests/common" } @@ -189,7 +196,7 @@ allocative = "0.3.3" ansi-to-tui = "7.0.0" anyhow = "1" arboard = { version = "3", features = ["wayland-data-control"] } -askama = "0.15.4" +arc-swap = "1.9.0" assert_cmd = "2" assert_matches = "1.5.0" async-channel = "2.3.1" @@ -215,22 +222,20 @@ dirs = "6" dotenvy = "0.15.7" dunce = "1.0.4" encoding_rs = "0.8.35" -fd-lock = "4.0.4" env-flags = "0.1.1" env_logger = "0.11.9" eventsource-stream = "0.2.3" -flate2 = "1.1.4" futures = { version = "0.3", default-features = false } gethostname = "1.1.0" globset = "0.4" hmac = "0.12.1" http = "1.3.1" +iana-time-zone = "0.1.64" icu_decimal = "2.1" icu_locale_core = "2.1" icu_provider = { version = "2.1", features = ["sync"] } ignore = "0.4.23" image = { version = "^0.25.9", default-features = false } -iana-time-zone = "0.1.64" include_dir = "0.7.4" indexmap = "2.12.0" insta = "1.46.3" @@ -272,7 +277,6 @@ regex-lite = "0.1.8" reqwest = "0.12" rmcp = { version = "0.15.0", default-features = false } runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" } -v8 = "=146.4.0" rustls = { version = "0.23", default-features = false, features = [ "ring", "std", @@ -311,7 +315,6 @@ supports-color = "3.0.2" syntect = "5" sys-locale = "0.3.2" tempfile = "3.23.0" -tar = "0.4.45" test-log = "0.2.19" textwrap = "0.16.2" thiserror = "2.0.17" @@ -342,6 +345,7 @@ unicode-width = "0.2" url = "2" urlencoding = "2.1" uuid = "1" +v8 = "=146.4.0" vt100 = "0.16.2" walkdir = "2.5.0" webbrowser = "1.0" diff --git a/codex-rs/README.md b/codex-rs/README.md index ad8a0506ff..6307668f39 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -50,7 +50,7 @@ You can enable notifications by configuring a script that is run whenever the ag ### `codex exec` to run Codex programmatically/non-interactively -To run Codex non-interactively, run `codex exec PROMPT` (you can also pass the prompt via `stdin`) and Codex will work on your task until it decides that it is done and exits. Output is printed to the terminal directly. You can set the `RUST_LOG` environment variable to see more about what's going on. +To run Codex non-interactively, run `codex exec PROMPT` (you can also pass the prompt via `stdin`) and Codex will work on your task until it decides that it is done and exits. If you provide both a prompt argument and piped stdin, Codex appends stdin as a `` block after the prompt so patterns like `echo "my output" | codex exec "Summarize this concisely"` work naturally. Output is printed to the terminal directly. You can set the `RUST_LOG` environment variable to see more about what's going on. Use `codex exec --ephemeral ...` to run without persisting session rollout files to disk. ### Experimenting with the Codex Sandbox diff --git a/codex-rs/analytics/Cargo.toml b/codex-rs/analytics/Cargo.toml index 9633eebb14..2239c4bc36 100644 --- a/codex-rs/analytics/Cargo.toml +++ b/codex-rs/analytics/Cargo.toml @@ -13,10 +13,12 @@ path = "src/lib.rs" workspace = true [dependencies] +codex-app-server-protocol = { workspace = true } codex-git-utils = { workspace = true } codex-login = { workspace = true } codex-plugin = { workspace = true } codex-protocol = { workspace = true } +os_info = { workspace = true } serde = { workspace = true, features = ["derive"] } sha1 = { workspace = true } tokio = { workspace = true, features = [ diff --git a/codex-rs/analytics/src/analytics_client.rs b/codex-rs/analytics/src/analytics_client.rs deleted file mode 100644 index ac8d10b6c1..0000000000 --- a/codex-rs/analytics/src/analytics_client.rs +++ /dev/null @@ -1,797 +0,0 @@ -use codex_git_utils::collect_git_info; -use codex_git_utils::get_git_repo_root; -use codex_login::AuthManager; -use codex_login::default_client::create_client; -use codex_login::default_client::originator; -use codex_plugin::PluginTelemetryMetadata; -use codex_protocol::protocol::SkillScope; -use serde::Serialize; -use sha1::Digest; -use sha1::Sha1; -use std::collections::HashSet; -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::Mutex; -use std::time::Duration; -use tokio::sync::mpsc; - -#[derive(Clone)] -pub struct TrackEventsContext { - pub model_slug: String, - pub thread_id: String, - pub turn_id: String, -} - -pub fn build_track_events_context( - model_slug: String, - thread_id: String, - turn_id: String, -) -> TrackEventsContext { - TrackEventsContext { - model_slug, - thread_id, - turn_id, - } -} - -#[derive(Clone, Debug)] -pub struct SkillInvocation { - pub skill_name: String, - pub skill_scope: SkillScope, - pub skill_path: PathBuf, - pub invocation_type: InvocationType, -} - -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum InvocationType { - Explicit, - Implicit, -} - -pub struct AppInvocation { - pub connector_id: Option, - pub app_name: Option, - pub invocation_type: Option, -} - -#[derive(Clone)] -pub(crate) struct AnalyticsEventsQueue { - sender: mpsc::Sender, - app_used_emitted_keys: Arc>>, - plugin_used_emitted_keys: Arc>>, -} - -#[derive(Clone)] -pub struct AnalyticsEventsClient { - queue: AnalyticsEventsQueue, - analytics_enabled: Option, -} - -impl AnalyticsEventsQueue { - pub(crate) fn new(auth_manager: Arc, base_url: String) -> Self { - let (sender, mut receiver) = mpsc::channel(ANALYTICS_EVENTS_QUEUE_SIZE); - tokio::spawn(async move { - while let Some(job) = receiver.recv().await { - match job { - TrackEventsJob::SkillInvocations(job) => { - send_track_skill_invocations(&auth_manager, &base_url, job).await; - } - TrackEventsJob::AppMentioned(job) => { - send_track_app_mentioned(&auth_manager, &base_url, job).await; - } - TrackEventsJob::AppUsed(job) => { - send_track_app_used(&auth_manager, &base_url, job).await; - } - TrackEventsJob::PluginUsed(job) => { - send_track_plugin_used(&auth_manager, &base_url, job).await; - } - TrackEventsJob::PluginInstalled(job) => { - send_track_plugin_installed(&auth_manager, &base_url, job).await; - } - TrackEventsJob::PluginUninstalled(job) => { - send_track_plugin_uninstalled(&auth_manager, &base_url, job).await; - } - TrackEventsJob::PluginEnabled(job) => { - send_track_plugin_enabled(&auth_manager, &base_url, job).await; - } - TrackEventsJob::PluginDisabled(job) => { - send_track_plugin_disabled(&auth_manager, &base_url, job).await; - } - } - } - }); - Self { - sender, - app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), - plugin_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), - } - } - - fn try_send(&self, job: TrackEventsJob) { - if self.sender.try_send(job).is_err() { - //TODO: add a metric for this - tracing::warn!("dropping analytics events: queue is full"); - } - } - - fn should_enqueue_app_used(&self, tracking: &TrackEventsContext, app: &AppInvocation) -> bool { - let Some(connector_id) = app.connector_id.as_ref() else { - return true; - }; - let mut emitted = self - .app_used_emitted_keys - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if emitted.len() >= ANALYTICS_EVENT_DEDUPE_MAX_KEYS { - emitted.clear(); - } - emitted.insert((tracking.turn_id.clone(), connector_id.clone())) - } - - fn should_enqueue_plugin_used( - &self, - tracking: &TrackEventsContext, - plugin: &PluginTelemetryMetadata, - ) -> bool { - let mut emitted = self - .plugin_used_emitted_keys - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if emitted.len() >= ANALYTICS_EVENT_DEDUPE_MAX_KEYS { - emitted.clear(); - } - emitted.insert((tracking.turn_id.clone(), plugin.plugin_id.as_key())) - } -} - -impl AnalyticsEventsClient { - pub fn new( - auth_manager: Arc, - base_url: String, - analytics_enabled: Option, - ) -> Self { - Self { - queue: AnalyticsEventsQueue::new(Arc::clone(&auth_manager), base_url), - analytics_enabled, - } - } - - pub fn track_skill_invocations( - &self, - tracking: TrackEventsContext, - invocations: Vec, - ) { - track_skill_invocations( - &self.queue, - self.analytics_enabled, - Some(tracking), - invocations, - ); - } - - pub fn track_app_mentioned(&self, tracking: TrackEventsContext, mentions: Vec) { - track_app_mentioned( - &self.queue, - self.analytics_enabled, - Some(tracking), - mentions, - ); - } - - pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { - track_app_used(&self.queue, self.analytics_enabled, Some(tracking), app); - } - - pub fn track_plugin_used(&self, tracking: TrackEventsContext, plugin: PluginTelemetryMetadata) { - track_plugin_used(&self.queue, self.analytics_enabled, Some(tracking), plugin); - } - - pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { - track_plugin_management( - &self.queue, - self.analytics_enabled, - PluginManagementEventType::Installed, - plugin, - ); - } - - pub fn track_plugin_uninstalled(&self, plugin: PluginTelemetryMetadata) { - track_plugin_management( - &self.queue, - self.analytics_enabled, - PluginManagementEventType::Uninstalled, - plugin, - ); - } - - pub fn track_plugin_enabled(&self, plugin: PluginTelemetryMetadata) { - track_plugin_management( - &self.queue, - self.analytics_enabled, - PluginManagementEventType::Enabled, - plugin, - ); - } - - pub fn track_plugin_disabled(&self, plugin: PluginTelemetryMetadata) { - track_plugin_management( - &self.queue, - self.analytics_enabled, - PluginManagementEventType::Disabled, - plugin, - ); - } -} - -enum TrackEventsJob { - SkillInvocations(TrackSkillInvocationsJob), - AppMentioned(TrackAppMentionedJob), - AppUsed(TrackAppUsedJob), - PluginUsed(TrackPluginUsedJob), - PluginInstalled(TrackPluginManagementJob), - PluginUninstalled(TrackPluginManagementJob), - PluginEnabled(TrackPluginManagementJob), - PluginDisabled(TrackPluginManagementJob), -} - -struct TrackSkillInvocationsJob { - analytics_enabled: Option, - tracking: TrackEventsContext, - invocations: Vec, -} - -struct TrackAppMentionedJob { - analytics_enabled: Option, - tracking: TrackEventsContext, - mentions: Vec, -} - -struct TrackAppUsedJob { - analytics_enabled: Option, - tracking: TrackEventsContext, - app: AppInvocation, -} - -struct TrackPluginUsedJob { - analytics_enabled: Option, - tracking: TrackEventsContext, - plugin: PluginTelemetryMetadata, -} - -struct TrackPluginManagementJob { - analytics_enabled: Option, - plugin: PluginTelemetryMetadata, -} - -#[derive(Clone, Copy)] -enum PluginManagementEventType { - Installed, - Uninstalled, - Enabled, - Disabled, -} - -const ANALYTICS_EVENTS_QUEUE_SIZE: usize = 256; -const ANALYTICS_EVENTS_TIMEOUT: Duration = Duration::from_secs(10); -const ANALYTICS_EVENT_DEDUPE_MAX_KEYS: usize = 4096; - -#[derive(Serialize)] -struct TrackEventsRequest { - events: Vec, -} - -#[derive(Serialize)] -#[serde(untagged)] -enum TrackEventRequest { - SkillInvocation(SkillInvocationEventRequest), - AppMentioned(CodexAppMentionedEventRequest), - AppUsed(CodexAppUsedEventRequest), - PluginUsed(CodexPluginUsedEventRequest), - PluginInstalled(CodexPluginEventRequest), - PluginUninstalled(CodexPluginEventRequest), - PluginEnabled(CodexPluginEventRequest), - PluginDisabled(CodexPluginEventRequest), -} - -#[derive(Serialize)] -struct SkillInvocationEventRequest { - event_type: &'static str, - skill_id: String, - skill_name: String, - event_params: SkillInvocationEventParams, -} - -#[derive(Serialize)] -struct SkillInvocationEventParams { - product_client_id: Option, - skill_scope: Option, - repo_url: Option, - thread_id: Option, - invoke_type: Option, - model_slug: Option, -} - -#[derive(Serialize)] -struct CodexAppMetadata { - connector_id: Option, - thread_id: Option, - turn_id: Option, - app_name: Option, - product_client_id: Option, - invoke_type: Option, - model_slug: Option, -} - -#[derive(Serialize)] -struct CodexAppMentionedEventRequest { - event_type: &'static str, - event_params: CodexAppMetadata, -} - -#[derive(Serialize)] -struct CodexAppUsedEventRequest { - event_type: &'static str, - event_params: CodexAppMetadata, -} - -#[derive(Serialize)] -struct CodexPluginMetadata { - plugin_id: Option, - plugin_name: Option, - marketplace_name: Option, - has_skills: Option, - mcp_server_count: Option, - connector_ids: Option>, - product_client_id: Option, -} - -#[derive(Serialize)] -struct CodexPluginUsedMetadata { - #[serde(flatten)] - plugin: CodexPluginMetadata, - thread_id: Option, - turn_id: Option, - model_slug: Option, -} - -#[derive(Serialize)] -struct CodexPluginEventRequest { - event_type: &'static str, - event_params: CodexPluginMetadata, -} - -#[derive(Serialize)] -struct CodexPluginUsedEventRequest { - event_type: &'static str, - event_params: CodexPluginUsedMetadata, -} - -pub(crate) fn track_skill_invocations( - queue: &AnalyticsEventsQueue, - analytics_enabled: Option, - tracking: Option, - invocations: Vec, -) { - if analytics_enabled == Some(false) { - return; - } - let Some(tracking) = tracking else { - return; - }; - if invocations.is_empty() { - return; - } - let job = TrackEventsJob::SkillInvocations(TrackSkillInvocationsJob { - analytics_enabled, - tracking, - invocations, - }); - queue.try_send(job); -} - -pub(crate) fn track_app_mentioned( - queue: &AnalyticsEventsQueue, - analytics_enabled: Option, - tracking: Option, - mentions: Vec, -) { - if analytics_enabled == Some(false) { - return; - } - let Some(tracking) = tracking else { - return; - }; - if mentions.is_empty() { - return; - } - let job = TrackEventsJob::AppMentioned(TrackAppMentionedJob { - analytics_enabled, - tracking, - mentions, - }); - queue.try_send(job); -} - -pub(crate) fn track_app_used( - queue: &AnalyticsEventsQueue, - analytics_enabled: Option, - tracking: Option, - app: AppInvocation, -) { - if analytics_enabled == Some(false) { - return; - } - let Some(tracking) = tracking else { - return; - }; - if !queue.should_enqueue_app_used(&tracking, &app) { - return; - } - let job = TrackEventsJob::AppUsed(TrackAppUsedJob { - analytics_enabled, - tracking, - app, - }); - queue.try_send(job); -} - -pub(crate) fn track_plugin_used( - queue: &AnalyticsEventsQueue, - analytics_enabled: Option, - tracking: Option, - plugin: PluginTelemetryMetadata, -) { - if analytics_enabled == Some(false) { - return; - } - let Some(tracking) = tracking else { - return; - }; - if !queue.should_enqueue_plugin_used(&tracking, &plugin) { - return; - } - let job = TrackEventsJob::PluginUsed(TrackPluginUsedJob { - analytics_enabled, - tracking, - plugin, - }); - queue.try_send(job); -} - -fn track_plugin_management( - queue: &AnalyticsEventsQueue, - analytics_enabled: Option, - event_type: PluginManagementEventType, - plugin: PluginTelemetryMetadata, -) { - if analytics_enabled == Some(false) { - return; - } - let job = TrackPluginManagementJob { - analytics_enabled, - plugin, - }; - let job = match event_type { - PluginManagementEventType::Installed => TrackEventsJob::PluginInstalled(job), - PluginManagementEventType::Uninstalled => TrackEventsJob::PluginUninstalled(job), - PluginManagementEventType::Enabled => TrackEventsJob::PluginEnabled(job), - PluginManagementEventType::Disabled => TrackEventsJob::PluginDisabled(job), - }; - queue.try_send(job); -} - -async fn send_track_skill_invocations( - auth_manager: &AuthManager, - base_url: &str, - job: TrackSkillInvocationsJob, -) { - let TrackSkillInvocationsJob { - analytics_enabled, - tracking, - invocations, - } = job; - let mut events = Vec::with_capacity(invocations.len()); - for invocation in invocations { - let skill_scope = match invocation.skill_scope { - SkillScope::User => "user", - SkillScope::Repo => "repo", - SkillScope::System => "system", - SkillScope::Admin => "admin", - }; - let repo_root = get_git_repo_root(invocation.skill_path.as_path()); - let repo_url = if let Some(root) = repo_root.as_ref() { - collect_git_info(root) - .await - .and_then(|info| info.repository_url) - } else { - None - }; - let skill_id = skill_id_for_local_skill( - repo_url.as_deref(), - repo_root.as_deref(), - invocation.skill_path.as_path(), - invocation.skill_name.as_str(), - ); - events.push(TrackEventRequest::SkillInvocation( - SkillInvocationEventRequest { - event_type: "skill_invocation", - skill_id, - skill_name: invocation.skill_name.clone(), - event_params: SkillInvocationEventParams { - thread_id: Some(tracking.thread_id.clone()), - invoke_type: Some(invocation.invocation_type), - model_slug: Some(tracking.model_slug.clone()), - product_client_id: Some(originator().value), - repo_url, - skill_scope: Some(skill_scope.to_string()), - }, - }, - )); - } - - send_track_events(auth_manager, analytics_enabled, base_url, events).await; -} - -async fn send_track_app_mentioned( - auth_manager: &AuthManager, - base_url: &str, - job: TrackAppMentionedJob, -) { - let TrackAppMentionedJob { - analytics_enabled, - tracking, - mentions, - } = job; - let events = mentions - .into_iter() - .map(|mention| { - let event_params = codex_app_metadata(&tracking, mention); - TrackEventRequest::AppMentioned(CodexAppMentionedEventRequest { - event_type: "codex_app_mentioned", - event_params, - }) - }) - .collect::>(); - - send_track_events(auth_manager, analytics_enabled, base_url, events).await; -} - -async fn send_track_app_used(auth_manager: &AuthManager, base_url: &str, job: TrackAppUsedJob) { - let TrackAppUsedJob { - analytics_enabled, - tracking, - app, - } = job; - let event_params = codex_app_metadata(&tracking, app); - let events = vec![TrackEventRequest::AppUsed(CodexAppUsedEventRequest { - event_type: "codex_app_used", - event_params, - })]; - - send_track_events(auth_manager, analytics_enabled, base_url, events).await; -} - -async fn send_track_plugin_used( - auth_manager: &AuthManager, - base_url: &str, - job: TrackPluginUsedJob, -) { - let TrackPluginUsedJob { - analytics_enabled, - tracking, - plugin, - } = job; - let events = vec![TrackEventRequest::PluginUsed(CodexPluginUsedEventRequest { - event_type: "codex_plugin_used", - event_params: codex_plugin_used_metadata(&tracking, plugin), - })]; - - send_track_events(auth_manager, analytics_enabled, base_url, events).await; -} - -async fn send_track_plugin_installed( - auth_manager: &AuthManager, - base_url: &str, - job: TrackPluginManagementJob, -) { - send_track_plugin_management_event(auth_manager, base_url, job, "codex_plugin_installed").await; -} - -async fn send_track_plugin_uninstalled( - auth_manager: &AuthManager, - base_url: &str, - job: TrackPluginManagementJob, -) { - send_track_plugin_management_event(auth_manager, base_url, job, "codex_plugin_uninstalled") - .await; -} - -async fn send_track_plugin_enabled( - auth_manager: &AuthManager, - base_url: &str, - job: TrackPluginManagementJob, -) { - send_track_plugin_management_event(auth_manager, base_url, job, "codex_plugin_enabled").await; -} - -async fn send_track_plugin_disabled( - auth_manager: &AuthManager, - base_url: &str, - job: TrackPluginManagementJob, -) { - send_track_plugin_management_event(auth_manager, base_url, job, "codex_plugin_disabled").await; -} - -async fn send_track_plugin_management_event( - auth_manager: &AuthManager, - base_url: &str, - job: TrackPluginManagementJob, - event_type: &'static str, -) { - let TrackPluginManagementJob { - analytics_enabled, - plugin, - } = job; - let event_params = codex_plugin_metadata(plugin); - let event = CodexPluginEventRequest { - event_type, - event_params, - }; - let events = vec![match event_type { - "codex_plugin_installed" => TrackEventRequest::PluginInstalled(event), - "codex_plugin_uninstalled" => TrackEventRequest::PluginUninstalled(event), - "codex_plugin_enabled" => TrackEventRequest::PluginEnabled(event), - "codex_plugin_disabled" => TrackEventRequest::PluginDisabled(event), - _ => unreachable!("unknown plugin management event type"), - }]; - - send_track_events(auth_manager, analytics_enabled, base_url, events).await; -} - -fn codex_app_metadata(tracking: &TrackEventsContext, app: AppInvocation) -> CodexAppMetadata { - CodexAppMetadata { - connector_id: app.connector_id, - thread_id: Some(tracking.thread_id.clone()), - turn_id: Some(tracking.turn_id.clone()), - app_name: app.app_name, - product_client_id: Some(originator().value), - invoke_type: app.invocation_type, - model_slug: Some(tracking.model_slug.clone()), - } -} - -fn codex_plugin_metadata(plugin: PluginTelemetryMetadata) -> CodexPluginMetadata { - let capability_summary = plugin.capability_summary; - CodexPluginMetadata { - plugin_id: Some(plugin.plugin_id.as_key()), - plugin_name: Some(plugin.plugin_id.plugin_name), - marketplace_name: Some(plugin.plugin_id.marketplace_name), - has_skills: capability_summary - .as_ref() - .map(|summary| summary.has_skills), - mcp_server_count: capability_summary - .as_ref() - .map(|summary| summary.mcp_server_names.len()), - connector_ids: capability_summary.map(|summary| { - summary - .app_connector_ids - .into_iter() - .map(|connector_id| connector_id.0) - .collect() - }), - product_client_id: Some(originator().value), - } -} - -fn codex_plugin_used_metadata( - tracking: &TrackEventsContext, - plugin: PluginTelemetryMetadata, -) -> CodexPluginUsedMetadata { - CodexPluginUsedMetadata { - plugin: codex_plugin_metadata(plugin), - thread_id: Some(tracking.thread_id.clone()), - turn_id: Some(tracking.turn_id.clone()), - model_slug: Some(tracking.model_slug.clone()), - } -} - -async fn send_track_events( - auth_manager: &AuthManager, - analytics_enabled: Option, - base_url: &str, - events: Vec, -) { - if analytics_enabled == Some(false) { - return; - } - if events.is_empty() { - return; - } - let Some(auth) = auth_manager.auth().await else { - return; - }; - if !auth.is_chatgpt_auth() { - return; - } - let access_token = match auth.get_token() { - Ok(token) => token, - Err(_) => return, - }; - let Some(account_id) = auth.get_account_id() else { - return; - }; - - let base_url = base_url.trim_end_matches('/'); - let url = format!("{base_url}/codex/analytics-events/events"); - let payload = TrackEventsRequest { events }; - - let response = create_client() - .post(&url) - .timeout(ANALYTICS_EVENTS_TIMEOUT) - .bearer_auth(&access_token) - .header("chatgpt-account-id", &account_id) - .header("Content-Type", "application/json") - .json(&payload) - .send() - .await; - - match response { - Ok(response) if response.status().is_success() => {} - Ok(response) => { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - tracing::warn!("events failed with status {status}: {body}"); - } - Err(err) => { - tracing::warn!("failed to send events request: {err}"); - } - } -} - -pub(crate) fn skill_id_for_local_skill( - repo_url: Option<&str>, - repo_root: Option<&Path>, - skill_path: &Path, - skill_name: &str, -) -> String { - let path = normalize_path_for_skill_id(repo_url, repo_root, skill_path); - let prefix = if let Some(url) = repo_url { - format!("repo_{url}") - } else { - "personal".to_string() - }; - let raw_id = format!("{prefix}_{path}_{skill_name}"); - let mut hasher = Sha1::new(); - hasher.update(raw_id.as_bytes()); - format!("{:x}", hasher.finalize()) -} - -/// Returns a normalized path for skill ID construction. -/// -/// - Repo-scoped skills use a path relative to the repo root. -/// - User/admin/system skills use an absolute path. -fn normalize_path_for_skill_id( - repo_url: Option<&str>, - repo_root: Option<&Path>, - skill_path: &Path, -) -> String { - let resolved_path = - std::fs::canonicalize(skill_path).unwrap_or_else(|_| skill_path.to_path_buf()); - match (repo_url, repo_root) { - (Some(_), Some(root)) => { - let resolved_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); - resolved_path - .strip_prefix(&resolved_root) - .unwrap_or(resolved_path.as_path()) - .to_string_lossy() - .replace('\\', "/") - } - _ => resolved_path.to_string_lossy().replace('\\', "/"), - } -} - -#[cfg(test)] -#[path = "analytics_client_tests.rs"] -mod tests; diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index f64da8f589..73ea42d760 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -1,21 +1,55 @@ -use super::AnalyticsEventsQueue; -use super::AppInvocation; -use super::CodexAppMentionedEventRequest; -use super::CodexAppUsedEventRequest; -use super::CodexPluginEventRequest; -use super::CodexPluginUsedEventRequest; -use super::InvocationType; -use super::TrackEventRequest; -use super::TrackEventsContext; -use super::codex_app_metadata; -use super::codex_plugin_metadata; -use super::codex_plugin_used_metadata; -use super::normalize_path_for_skill_id; +use crate::client::AnalyticsEventsQueue; +use crate::events::AppServerRpcTransport; +use crate::events::CodexAppMentionedEventRequest; +use crate::events::CodexAppServerClientMetadata; +use crate::events::CodexAppUsedEventRequest; +use crate::events::CodexPluginEventRequest; +use crate::events::CodexPluginUsedEventRequest; +use crate::events::CodexRuntimeMetadata; +use crate::events::ThreadInitializationMode; +use crate::events::ThreadInitializedEvent; +use crate::events::ThreadInitializedEventParams; +use crate::events::TrackEventRequest; +use crate::events::codex_app_metadata; +use crate::events::codex_plugin_metadata; +use crate::events::codex_plugin_used_metadata; +use crate::events::subagent_thread_started_event_request; +use crate::facts::AnalyticsFact; +use crate::facts::AppInvocation; +use crate::facts::AppMentionedInput; +use crate::facts::AppUsedInput; +use crate::facts::CustomAnalyticsFact; +use crate::facts::InvocationType; +use crate::facts::PluginState; +use crate::facts::PluginStateChangedInput; +use crate::facts::PluginUsedInput; +use crate::facts::SkillInvocation; +use crate::facts::SkillInvokedInput; +use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TrackEventsContext; +use crate::reducer::AnalyticsReducer; +use crate::reducer::normalize_path_for_skill_id; +use crate::reducer::skill_id_for_local_skill; +use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; +use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::InitializeCapabilities; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; +use codex_app_server_protocol::SessionSource as AppServerSessionSource; +use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; +use codex_login::default_client::DEFAULT_ORIGINATOR; use codex_login::default_client::originator; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -24,6 +58,62 @@ use std::sync::Arc; use std::sync::Mutex; use tokio::sync::mpsc; +fn sample_thread(thread_id: &str, ephemeral: bool) -> Thread { + Thread { + id: thread_id.to_string(), + forked_from_id: None, + preview: "first prompt".to_string(), + ephemeral, + model_provider: "openai".to_string(), + created_at: 1, + updated_at: 2, + status: AppServerThreadStatus::Idle, + path: None, + cwd: PathBuf::from("/tmp"), + cli_version: "0.0.0".to_string(), + source: AppServerSessionSource::Exec, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: Vec::new(), + } +} + +fn sample_thread_start_response(thread_id: &str, ephemeral: bool, model: &str) -> ClientResponse { + ClientResponse::ThreadStart { + request_id: RequestId::Integer(1), + response: ThreadStartResponse { + thread: sample_thread(thread_id, ephemeral), + model: model.to_string(), + model_provider: "openai".to_string(), + service_tier: None, + cwd: PathBuf::from("/tmp"), + approval_policy: AppServerAskForApproval::OnFailure, + approvals_reviewer: AppServerApprovalsReviewer::User, + sandbox: AppServerSandboxPolicy::DangerFullAccess, + reasoning_effort: None, + }, + } +} + +fn sample_thread_resume_response(thread_id: &str, ephemeral: bool, model: &str) -> ClientResponse { + ClientResponse::ThreadResume { + request_id: RequestId::Integer(2), + response: ThreadResumeResponse { + thread: sample_thread(thread_id, ephemeral), + model: model.to_string(), + model_provider: "openai".to_string(), + service_tier: None, + cwd: PathBuf::from("/tmp"), + approval_policy: AppServerAskForApproval::OnFailure, + approvals_reviewer: AppServerApprovalsReviewer::User, + sandbox: AppServerSandboxPolicy::DangerFullAccess, + reasoning_effort: None, + }, + } +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -49,7 +139,11 @@ fn normalize_path_for_skill_id_repo_scoped_uses_relative_path() { fn normalize_path_for_skill_id_user_scoped_uses_absolute_path() { let skill_path = PathBuf::from("/Users/abc/.codex/skills/doc/SKILL.md"); - let path = normalize_path_for_skill_id(None, None, skill_path.as_path()); + let path = normalize_path_for_skill_id( + /*repo_url*/ None, + /*repo_root*/ None, + skill_path.as_path(), + ); let expected = expected_absolute_path(&skill_path); assert_eq!(path, expected); @@ -59,7 +153,11 @@ fn normalize_path_for_skill_id_user_scoped_uses_absolute_path() { fn normalize_path_for_skill_id_admin_scoped_uses_absolute_path() { let skill_path = PathBuf::from("/etc/codex/skills/doc/SKILL.md"); - let path = normalize_path_for_skill_id(None, None, skill_path.as_path()); + let path = normalize_path_for_skill_id( + /*repo_url*/ None, + /*repo_root*/ None, + skill_path.as_path(), + ); let expected = expected_absolute_path(&skill_path); assert_eq!(path, expected); @@ -186,6 +284,320 @@ fn app_used_dedupe_is_keyed_by_turn_and_connector() { assert_eq!(queue.should_enqueue_app_used(&turn_2, &app), true); } +#[test] +fn thread_initialized_event_serializes_expected_shape() { + let event = TrackEventRequest::ThreadInitialized(ThreadInitializedEvent { + event_type: "codex_thread_initialized", + event_params: ThreadInitializedEventParams { + thread_id: "thread-0".to_string(), + app_server_client: CodexAppServerClientMetadata { + product_client_id: DEFAULT_ORIGINATOR.to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + rpc_transport: AppServerRpcTransport::Stdio, + experimental_api_enabled: Some(true), + }, + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.1.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + model: "gpt-5".to_string(), + ephemeral: true, + thread_source: Some("user"), + initialization_mode: ThreadInitializationMode::New, + subagent_source: None, + parent_thread_id: None, + created_at: 1, + }, + }); + + let payload = serde_json::to_value(&event).expect("serialize thread initialized event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_thread_initialized", + "event_params": { + "thread_id": "thread-0", + "app_server_client": { + "product_client_id": DEFAULT_ORIGINATOR, + "client_name": "codex-tui", + "client_version": "1.0.0", + "rpc_transport": "stdio", + "experimental_api_enabled": true + }, + "runtime": { + "codex_rs_version": "0.1.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "model": "gpt-5", + "ephemeral": true, + "thread_source": "user", + "initialization_mode": "new", + "subagent_source": null, + "parent_thread_id": null, + "created_at": 1 + } + }) + ); +} + +#[tokio::test] +async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialized() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_start_response( + "thread-no-client", + /*ephemeral*/ false, + "gpt-5", + )), + }, + &mut events, + ) + .await; + assert!(events.is_empty(), "thread events should require initialize"); + + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: Some(InitializeCapabilities { + experimental_api: false, + opt_out_notification_methods: None, + }), + }, + product_client_id: DEFAULT_ORIGINATOR.to_string(), + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.99.0".to_string(), + runtime_os: "linux".to_string(), + runtime_os_version: "24.04".to_string(), + runtime_arch: "x86_64".to_string(), + }, + rpc_transport: AppServerRpcTransport::Websocket, + }, + &mut events, + ) + .await; + assert!(events.is_empty(), "initialize should not publish by itself"); + + reducer + .ingest( + AnalyticsFact::Response { + connection_id: 7, + response: Box::new(sample_thread_resume_response( + "thread-1", /*ephemeral*/ true, "gpt-5", + )), + }, + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload.as_array().expect("events array").len(), 1); + assert_eq!(payload[0]["event_type"], "codex_thread_initialized"); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_ORIGINATOR + ); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["client_name"], + "codex-tui" + ); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["client_version"], + "1.0.0" + ); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["rpc_transport"], + "websocket" + ); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["experimental_api_enabled"], + false + ); + assert_eq!( + payload[0]["event_params"]["runtime"]["codex_rs_version"], + "0.99.0" + ); + assert_eq!(payload[0]["event_params"]["runtime"]["runtime_os"], "linux"); + assert_eq!( + payload[0]["event_params"]["runtime"]["runtime_os_version"], + "24.04" + ); + assert_eq!( + payload[0]["event_params"]["runtime"]["runtime_arch"], + "x86_64" + ); + assert_eq!(payload[0]["event_params"]["initialization_mode"], "resumed"); + assert_eq!(payload[0]["event_params"]["thread_source"], "user"); + assert_eq!(payload[0]["event_params"]["subagent_source"], json!(null)); + assert_eq!(payload[0]["event_params"]["parent_thread_id"], json!(null)); +} + +#[test] +fn subagent_thread_started_review_serializes_expected_shape() { + let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( + SubAgentThreadStartedInput { + thread_id: "thread-review".to_string(), + product_client_id: "codex-tui".to_string(), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), + model: "gpt-5".to_string(), + ephemeral: false, + subagent_source: SubAgentSource::Review, + created_at: 123, + }, + )); + + let payload = serde_json::to_value(&event).expect("serialize review subagent event"); + assert_eq!(payload["event_params"]["thread_source"], "subagent"); + assert_eq!( + payload["event_params"]["app_server_client"]["product_client_id"], + "codex-tui" + ); + assert_eq!( + payload["event_params"]["app_server_client"]["client_name"], + "codex-tui" + ); + assert_eq!( + payload["event_params"]["app_server_client"]["client_version"], + "1.0.0" + ); + assert_eq!( + payload["event_params"]["app_server_client"]["rpc_transport"], + "in_process" + ); + assert_eq!(payload["event_params"]["created_at"], 123); + assert_eq!(payload["event_params"]["initialization_mode"], "new"); + assert_eq!(payload["event_params"]["subagent_source"], "review"); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); +} + +#[test] +fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() { + let parent_thread_id = + codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") + .expect("valid thread id"); + let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( + SubAgentThreadStartedInput { + thread_id: "thread-spawn".to_string(), + product_client_id: "codex-tui".to_string(), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), + model: "gpt-5".to_string(), + ephemeral: true, + subagent_source: SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }, + created_at: 124, + }, + )); + + let payload = serde_json::to_value(&event).expect("serialize thread spawn subagent event"); + assert_eq!(payload["event_params"]["thread_source"], "subagent"); + assert_eq!(payload["event_params"]["subagent_source"], "thread_spawn"); + assert_eq!( + payload["event_params"]["parent_thread_id"], + "11111111-1111-1111-1111-111111111111" + ); +} + +#[test] +fn subagent_thread_started_memory_consolidation_serializes_expected_shape() { + let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( + SubAgentThreadStartedInput { + thread_id: "thread-memory".to_string(), + product_client_id: "codex-tui".to_string(), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), + model: "gpt-5".to_string(), + ephemeral: false, + subagent_source: SubAgentSource::MemoryConsolidation, + created_at: 125, + }, + )); + + let payload = + serde_json::to_value(&event).expect("serialize memory consolidation subagent event"); + assert_eq!( + payload["event_params"]["subagent_source"], + "memory_consolidation" + ); + assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); +} + +#[test] +fn subagent_thread_started_other_serializes_expected_shape() { + let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( + SubAgentThreadStartedInput { + thread_id: "thread-guardian".to_string(), + product_client_id: "codex-tui".to_string(), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), + model: "gpt-5".to_string(), + ephemeral: false, + subagent_source: SubAgentSource::Other("guardian".to_string()), + created_at: 126, + }, + )); + + let payload = serde_json::to_value(&event).expect("serialize other subagent event"); + assert_eq!(payload["event_params"]["subagent_source"], "guardian"); +} + +#[tokio::test] +async fn subagent_thread_started_publishes_without_initialize() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted( + SubAgentThreadStartedInput { + thread_id: "thread-review".to_string(), + product_client_id: "codex-tui".to_string(), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), + model: "gpt-5".to_string(), + ephemeral: false, + subagent_source: SubAgentSource::Review, + created_at: 127, + }, + )), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload.as_array().expect("events array").len(), 1); + assert_eq!(payload[0]["event_type"], "codex_thread_initialized"); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["product_client_id"], + "codex-tui" + ); + assert_eq!(payload[0]["event_params"]["thread_source"], "subagent"); + assert_eq!(payload[0]["event_params"]["subagent_source"], "review"); +} + #[test] fn plugin_used_event_serializes_expected_shape() { let tracking = TrackEventsContext { @@ -272,6 +684,145 @@ fn plugin_used_dedupe_is_keyed_by_turn_and_plugin() { assert_eq!(queue.should_enqueue_plugin_used(&turn_2, &plugin), true); } +#[tokio::test] +async fn reducer_ingests_skill_invoked_fact() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }; + let skill_path = PathBuf::from("/Users/abc/.codex/skills/doc/SKILL.md"); + let expected_skill_id = skill_id_for_local_skill( + /*repo_url*/ None, + /*repo_root*/ None, + skill_path.as_path(), + "doc", + ); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::SkillInvoked(SkillInvokedInput { + tracking, + invocations: vec![SkillInvocation { + skill_name: "doc".to_string(), + skill_scope: codex_protocol::protocol::SkillScope::User, + skill_path, + invocation_type: InvocationType::Explicit, + }], + })), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!( + payload, + json!([{ + "event_type": "skill_invocation", + "skill_id": expected_skill_id, + "skill_name": "doc", + "event_params": { + "product_client_id": originator().value, + "skill_scope": "user", + "repo_url": null, + "thread_id": "thread-1", + "invoke_type": "explicit", + "model_slug": "gpt-5" + } + }]) + ); +} + +#[tokio::test] +async fn reducer_ingests_app_and_plugin_facts() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }; + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::AppMentioned(AppMentionedInput { + tracking: tracking.clone(), + mentions: vec![AppInvocation { + connector_id: Some("calendar".to_string()), + app_name: Some("Calendar".to_string()), + invocation_type: Some(InvocationType::Explicit), + }], + })), + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::AppUsed(AppUsedInput { + tracking: tracking.clone(), + app: AppInvocation { + connector_id: Some("drive".to_string()), + app_name: Some("Drive".to_string()), + invocation_type: Some(InvocationType::Implicit), + }, + })), + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::PluginUsed(PluginUsedInput { + tracking, + plugin: sample_plugin_metadata(), + })), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload.as_array().expect("events array").len(), 3); + assert_eq!(payload[0]["event_type"], "codex_app_mentioned"); + assert_eq!(payload[1]["event_type"], "codex_app_used"); + assert_eq!(payload[2]["event_type"], "codex_plugin_used"); +} + +#[tokio::test] +async fn reducer_ingests_plugin_state_changed_fact() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::PluginStateChanged( + PluginStateChangedInput { + plugin: sample_plugin_metadata(), + state: PluginState::Disabled, + }, + )), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!( + payload, + json!([{ + "event_type": "codex_plugin_disabled", + "event_params": { + "plugin_id": "sample@test", + "plugin_name": "sample", + "marketplace_name": "test", + "has_skills": true, + "mcp_server_count": 2, + "connector_ids": ["calendar", "drive"], + "product_client_id": originator().value + } + }]) + ); +} + fn sample_plugin_metadata() -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs new file mode 100644 index 0000000000..dd300a7bd6 --- /dev/null +++ b/codex-rs/analytics/src/client.rs @@ -0,0 +1,279 @@ +use crate::events::AppServerRpcTransport; +use crate::events::TrackEventRequest; +use crate::events::TrackEventsRequest; +use crate::events::current_runtime_metadata; +use crate::facts::AnalyticsFact; +use crate::facts::AppInvocation; +use crate::facts::AppMentionedInput; +use crate::facts::AppUsedInput; +use crate::facts::CustomAnalyticsFact; +use crate::facts::PluginState; +use crate::facts::PluginStateChangedInput; +use crate::facts::SkillInvocation; +use crate::facts::SkillInvokedInput; +use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TrackEventsContext; +use crate::reducer::AnalyticsReducer; +use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::InitializeParams; +use codex_login::AuthManager; +use codex_login::default_client::create_client; +use codex_plugin::PluginTelemetryMetadata; +use std::collections::HashSet; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::Duration; +use tokio::sync::mpsc; + +const ANALYTICS_EVENTS_QUEUE_SIZE: usize = 256; +const ANALYTICS_EVENTS_TIMEOUT: Duration = Duration::from_secs(10); +const ANALYTICS_EVENT_DEDUPE_MAX_KEYS: usize = 4096; + +#[derive(Clone)] +pub(crate) struct AnalyticsEventsQueue { + pub(crate) sender: mpsc::Sender, + pub(crate) app_used_emitted_keys: Arc>>, + pub(crate) plugin_used_emitted_keys: Arc>>, +} + +#[derive(Clone)] +pub struct AnalyticsEventsClient { + queue: AnalyticsEventsQueue, + analytics_enabled: Option, +} + +impl AnalyticsEventsQueue { + pub(crate) fn new(auth_manager: Arc, base_url: String) -> Self { + let (sender, mut receiver) = mpsc::channel(ANALYTICS_EVENTS_QUEUE_SIZE); + tokio::spawn(async move { + let mut reducer = AnalyticsReducer::default(); + while let Some(input) = receiver.recv().await { + let mut events = Vec::new(); + reducer.ingest(input, &mut events).await; + send_track_events(&auth_manager, &base_url, events).await; + } + }); + Self { + sender, + app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), + plugin_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), + } + } + + fn try_send(&self, input: AnalyticsFact) { + if self.sender.try_send(input).is_err() { + //TODO: add a metric for this + tracing::warn!("dropping analytics events: queue is full"); + } + } + + pub(crate) fn should_enqueue_app_used( + &self, + tracking: &TrackEventsContext, + app: &AppInvocation, + ) -> bool { + let Some(connector_id) = app.connector_id.as_ref() else { + return true; + }; + let mut emitted = self + .app_used_emitted_keys + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if emitted.len() >= ANALYTICS_EVENT_DEDUPE_MAX_KEYS { + emitted.clear(); + } + emitted.insert((tracking.turn_id.clone(), connector_id.clone())) + } + + pub(crate) fn should_enqueue_plugin_used( + &self, + tracking: &TrackEventsContext, + plugin: &PluginTelemetryMetadata, + ) -> bool { + let mut emitted = self + .plugin_used_emitted_keys + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if emitted.len() >= ANALYTICS_EVENT_DEDUPE_MAX_KEYS { + emitted.clear(); + } + emitted.insert((tracking.turn_id.clone(), plugin.plugin_id.as_key())) + } +} + +impl AnalyticsEventsClient { + pub fn new( + auth_manager: Arc, + base_url: String, + analytics_enabled: Option, + ) -> Self { + Self { + queue: AnalyticsEventsQueue::new(Arc::clone(&auth_manager), base_url), + analytics_enabled, + } + } + + pub fn track_skill_invocations( + &self, + tracking: TrackEventsContext, + invocations: Vec, + ) { + if invocations.is_empty() { + return; + } + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::SkillInvoked( + SkillInvokedInput { + tracking, + invocations, + }, + ))); + } + + pub fn track_initialize( + &self, + connection_id: u64, + params: InitializeParams, + product_client_id: String, + rpc_transport: AppServerRpcTransport, + ) { + self.record_fact(AnalyticsFact::Initialize { + connection_id, + params, + product_client_id, + runtime: current_runtime_metadata(), + rpc_transport, + }); + } + + pub fn track_subagent_thread_started(&self, input: SubAgentThreadStartedInput) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::SubAgentThreadStarted(input), + )); + } + + pub fn track_app_mentioned(&self, tracking: TrackEventsContext, mentions: Vec) { + if mentions.is_empty() { + return; + } + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::AppMentioned( + AppMentionedInput { tracking, mentions }, + ))); + } + + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { + if !self.queue.should_enqueue_app_used(&tracking, &app) { + return; + } + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::AppUsed( + AppUsedInput { tracking, app }, + ))); + } + + pub fn track_plugin_used(&self, tracking: TrackEventsContext, plugin: PluginTelemetryMetadata) { + if !self.queue.should_enqueue_plugin_used(&tracking, &plugin) { + return; + } + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::PluginUsed( + crate::facts::PluginUsedInput { tracking, plugin }, + ))); + } + + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { + plugin, + state: PluginState::Installed, + }), + )); + } + + pub fn track_plugin_uninstalled(&self, plugin: PluginTelemetryMetadata) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { + plugin, + state: PluginState::Uninstalled, + }), + )); + } + + pub fn track_plugin_enabled(&self, plugin: PluginTelemetryMetadata) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { + plugin, + state: PluginState::Enabled, + }), + )); + } + + pub fn track_plugin_disabled(&self, plugin: PluginTelemetryMetadata) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { + plugin, + state: PluginState::Disabled, + }), + )); + } + + pub(crate) fn record_fact(&self, input: AnalyticsFact) { + if self.analytics_enabled == Some(false) { + return; + } + self.queue.try_send(input); + } + + pub fn track_response(&self, connection_id: u64, response: ClientResponse) { + self.record_fact(AnalyticsFact::Response { + connection_id, + response: Box::new(response), + }); + } +} + +async fn send_track_events( + auth_manager: &AuthManager, + base_url: &str, + events: Vec, +) { + if events.is_empty() { + return; + } + let Some(auth) = auth_manager.auth().await else { + return; + }; + if !auth.is_chatgpt_auth() { + return; + } + let access_token = match auth.get_token() { + Ok(token) => token, + Err(_) => return, + }; + let Some(account_id) = auth.get_account_id() else { + return; + }; + + let base_url = base_url.trim_end_matches('/'); + let url = format!("{base_url}/codex/analytics-events/events"); + let payload = TrackEventsRequest { events }; + + let response = create_client() + .post(&url) + .timeout(ANALYTICS_EVENTS_TIMEOUT) + .bearer_auth(&access_token) + .header("chatgpt-account-id", &account_id) + .header("Content-Type", "application/json") + .json(&payload) + .send() + .await; + + match response { + Ok(response) if response.status().is_success() => {} + Ok(response) => { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + tracing::warn!("events failed with status {status}: {body}"); + } + Err(err) => { + tracing::warn!("failed to send events request: {err}"); + } + } +} diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs new file mode 100644 index 0000000000..885e93bbb9 --- /dev/null +++ b/codex-rs/analytics/src/events.rs @@ -0,0 +1,278 @@ +use crate::facts::AppInvocation; +use crate::facts::InvocationType; +use crate::facts::PluginState; +use crate::facts::SubAgentThreadStartedInput; +use crate::facts::TrackEventsContext; +use codex_login::default_client::originator; +use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use serde::Serialize; + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AppServerRpcTransport { + Stdio, + Websocket, + InProcess, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ThreadInitializationMode { + New, + Forked, + Resumed, +} + +#[derive(Serialize)] +pub(crate) struct TrackEventsRequest { + pub(crate) events: Vec, +} + +#[derive(Serialize)] +#[serde(untagged)] +pub(crate) enum TrackEventRequest { + SkillInvocation(SkillInvocationEventRequest), + ThreadInitialized(ThreadInitializedEvent), + AppMentioned(CodexAppMentionedEventRequest), + AppUsed(CodexAppUsedEventRequest), + PluginUsed(CodexPluginUsedEventRequest), + PluginInstalled(CodexPluginEventRequest), + PluginUninstalled(CodexPluginEventRequest), + PluginEnabled(CodexPluginEventRequest), + PluginDisabled(CodexPluginEventRequest), +} + +#[derive(Serialize)] +pub(crate) struct SkillInvocationEventRequest { + pub(crate) event_type: &'static str, + pub(crate) skill_id: String, + pub(crate) skill_name: String, + pub(crate) event_params: SkillInvocationEventParams, +} + +#[derive(Serialize)] +pub(crate) struct SkillInvocationEventParams { + pub(crate) product_client_id: Option, + pub(crate) skill_scope: Option, + pub(crate) repo_url: Option, + pub(crate) thread_id: Option, + pub(crate) invoke_type: Option, + pub(crate) model_slug: Option, +} + +#[derive(Clone, Serialize)] +pub(crate) struct CodexAppServerClientMetadata { + pub(crate) product_client_id: String, + pub(crate) client_name: Option, + pub(crate) client_version: Option, + pub(crate) rpc_transport: AppServerRpcTransport, + pub(crate) experimental_api_enabled: Option, +} + +#[derive(Clone, Serialize)] +pub(crate) struct CodexRuntimeMetadata { + pub(crate) codex_rs_version: String, + pub(crate) runtime_os: String, + pub(crate) runtime_os_version: String, + pub(crate) runtime_arch: String, +} + +#[derive(Serialize)] +pub(crate) struct ThreadInitializedEventParams { + pub(crate) thread_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) model: String, + pub(crate) ephemeral: bool, + pub(crate) thread_source: Option<&'static str>, + pub(crate) initialization_mode: ThreadInitializationMode, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) created_at: u64, +} + +#[derive(Serialize)] +pub(crate) struct ThreadInitializedEvent { + pub(crate) event_type: &'static str, + pub(crate) event_params: ThreadInitializedEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexAppMetadata { + pub(crate) connector_id: Option, + pub(crate) thread_id: Option, + pub(crate) turn_id: Option, + pub(crate) app_name: Option, + pub(crate) product_client_id: Option, + pub(crate) invoke_type: Option, + pub(crate) model_slug: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexAppMentionedEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexAppMetadata, +} + +#[derive(Serialize)] +pub(crate) struct CodexAppUsedEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexAppMetadata, +} + +#[derive(Serialize)] +pub(crate) struct CodexPluginMetadata { + pub(crate) plugin_id: Option, + pub(crate) plugin_name: Option, + pub(crate) marketplace_name: Option, + pub(crate) has_skills: Option, + pub(crate) mcp_server_count: Option, + pub(crate) connector_ids: Option>, + pub(crate) product_client_id: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexPluginUsedMetadata { + #[serde(flatten)] + pub(crate) plugin: CodexPluginMetadata, + pub(crate) thread_id: Option, + pub(crate) turn_id: Option, + pub(crate) model_slug: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexPluginEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexPluginMetadata, +} + +#[derive(Serialize)] +pub(crate) struct CodexPluginUsedEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexPluginUsedMetadata, +} + +pub(crate) fn plugin_state_event_type(state: PluginState) -> &'static str { + match state { + PluginState::Installed => "codex_plugin_installed", + PluginState::Uninstalled => "codex_plugin_uninstalled", + PluginState::Enabled => "codex_plugin_enabled", + PluginState::Disabled => "codex_plugin_disabled", + } +} + +pub(crate) fn codex_app_metadata( + tracking: &TrackEventsContext, + app: AppInvocation, +) -> CodexAppMetadata { + CodexAppMetadata { + connector_id: app.connector_id, + thread_id: Some(tracking.thread_id.clone()), + turn_id: Some(tracking.turn_id.clone()), + app_name: app.app_name, + product_client_id: Some(originator().value), + invoke_type: app.invocation_type, + model_slug: Some(tracking.model_slug.clone()), + } +} + +pub(crate) fn codex_plugin_metadata(plugin: PluginTelemetryMetadata) -> CodexPluginMetadata { + let capability_summary = plugin.capability_summary; + CodexPluginMetadata { + plugin_id: Some(plugin.plugin_id.as_key()), + plugin_name: Some(plugin.plugin_id.plugin_name), + marketplace_name: Some(plugin.plugin_id.marketplace_name), + has_skills: capability_summary + .as_ref() + .map(|summary| summary.has_skills), + mcp_server_count: capability_summary + .as_ref() + .map(|summary| summary.mcp_server_names.len()), + connector_ids: capability_summary.map(|summary| { + summary + .app_connector_ids + .into_iter() + .map(|connector_id| connector_id.0) + .collect() + }), + product_client_id: Some(originator().value), + } +} + +pub(crate) fn codex_plugin_used_metadata( + tracking: &TrackEventsContext, + plugin: PluginTelemetryMetadata, +) -> CodexPluginUsedMetadata { + CodexPluginUsedMetadata { + plugin: codex_plugin_metadata(plugin), + thread_id: Some(tracking.thread_id.clone()), + turn_id: Some(tracking.turn_id.clone()), + model_slug: Some(tracking.model_slug.clone()), + } +} + +pub(crate) fn thread_source_name(thread_source: &SessionSource) -> Option<&'static str> { + match thread_source { + SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), + SessionSource::SubAgent(_) => Some("subagent"), + SessionSource::Mcp | SessionSource::Custom(_) | SessionSource::Unknown => None, + } +} + +pub(crate) fn current_runtime_metadata() -> CodexRuntimeMetadata { + let os_info = os_info::get(); + CodexRuntimeMetadata { + codex_rs_version: env!("CARGO_PKG_VERSION").to_string(), + runtime_os: std::env::consts::OS.to_string(), + runtime_os_version: os_info.version().to_string(), + runtime_arch: std::env::consts::ARCH.to_string(), + } +} + +pub(crate) fn subagent_thread_started_event_request( + input: SubAgentThreadStartedInput, +) -> ThreadInitializedEvent { + let event_params = ThreadInitializedEventParams { + thread_id: input.thread_id, + app_server_client: CodexAppServerClientMetadata { + product_client_id: input.product_client_id, + client_name: Some(input.client_name), + client_version: Some(input.client_version), + rpc_transport: AppServerRpcTransport::InProcess, + experimental_api_enabled: None, + }, + runtime: current_runtime_metadata(), + model: input.model, + ephemeral: input.ephemeral, + thread_source: Some("subagent"), + initialization_mode: ThreadInitializationMode::New, + subagent_source: Some(subagent_source_name(&input.subagent_source)), + parent_thread_id: subagent_parent_thread_id(&input.subagent_source), + created_at: input.created_at, + }; + ThreadInitializedEvent { + event_type: "codex_thread_initialized", + event_params, + } +} + +fn subagent_source_name(subagent_source: &SubAgentSource) -> String { + match subagent_source { + SubAgentSource::Review => "review".to_string(), + SubAgentSource::Compact => "compact".to_string(), + SubAgentSource::ThreadSpawn { .. } => "thread_spawn".to_string(), + SubAgentSource::MemoryConsolidation => "memory_consolidation".to_string(), + SubAgentSource::Other(other) => other.clone(), + } +} + +fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option { + match subagent_source { + SubAgentSource::ThreadSpawn { + parent_thread_id, .. + } => Some(parent_thread_id.to_string()), + _ => None, + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs new file mode 100644 index 0000000000..e19d15d847 --- /dev/null +++ b/codex-rs/analytics/src/facts.rs @@ -0,0 +1,130 @@ +use crate::events::AppServerRpcTransport; +use crate::events::CodexRuntimeMetadata; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::SubAgentSource; +use serde::Serialize; +use std::path::PathBuf; + +#[derive(Clone)] +pub struct TrackEventsContext { + pub model_slug: String, + pub thread_id: String, + pub turn_id: String, +} + +pub fn build_track_events_context( + model_slug: String, + thread_id: String, + turn_id: String, +) -> TrackEventsContext { + TrackEventsContext { + model_slug, + thread_id, + turn_id, + } +} + +#[derive(Clone, Debug)] +pub struct SkillInvocation { + pub skill_name: String, + pub skill_scope: SkillScope, + pub skill_path: PathBuf, + pub invocation_type: InvocationType, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum InvocationType { + Explicit, + Implicit, +} + +pub struct AppInvocation { + pub connector_id: Option, + pub app_name: Option, + pub invocation_type: Option, +} + +#[derive(Clone)] +pub struct SubAgentThreadStartedInput { + pub thread_id: String, + pub product_client_id: String, + pub client_name: String, + pub client_version: String, + pub model: String, + pub ephemeral: bool, + pub subagent_source: SubAgentSource, + pub created_at: u64, +} + +#[allow(dead_code)] +pub(crate) enum AnalyticsFact { + Initialize { + connection_id: u64, + params: InitializeParams, + product_client_id: String, + runtime: CodexRuntimeMetadata, + rpc_transport: AppServerRpcTransport, + }, + Request { + connection_id: u64, + request_id: RequestId, + request: Box, + }, + Response { + connection_id: u64, + response: Box, + }, + Notification(Box), + // Facts that do not naturally exist on the app-server protocol surface, or + // would require non-trivial protocol reshaping on this branch. + Custom(CustomAnalyticsFact), +} + +pub(crate) enum CustomAnalyticsFact { + SubAgentThreadStarted(SubAgentThreadStartedInput), + SkillInvoked(SkillInvokedInput), + AppMentioned(AppMentionedInput), + AppUsed(AppUsedInput), + PluginUsed(PluginUsedInput), + PluginStateChanged(PluginStateChangedInput), +} + +pub(crate) struct SkillInvokedInput { + pub tracking: TrackEventsContext, + pub invocations: Vec, +} + +pub(crate) struct AppMentionedInput { + pub tracking: TrackEventsContext, + pub mentions: Vec, +} + +pub(crate) struct AppUsedInput { + pub tracking: TrackEventsContext, + pub app: AppInvocation, +} + +pub(crate) struct PluginUsedInput { + pub tracking: TrackEventsContext, + pub plugin: PluginTelemetryMetadata, +} + +pub(crate) struct PluginStateChangedInput { + pub plugin: PluginTelemetryMetadata, + pub state: PluginState, +} + +#[derive(Clone, Copy)] +pub(crate) enum PluginState { + Installed, + Uninstalled, + Enabled, + Disabled, +} diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index e36f5d71b5..f2f76ca8cf 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -1,8 +1,16 @@ -mod analytics_client; +mod client; +mod events; +mod facts; +mod reducer; -pub use analytics_client::AnalyticsEventsClient; -pub use analytics_client::AppInvocation; -pub use analytics_client::InvocationType; -pub use analytics_client::SkillInvocation; -pub use analytics_client::TrackEventsContext; -pub use analytics_client::build_track_events_context; +pub use client::AnalyticsEventsClient; +pub use events::AppServerRpcTransport; +pub use facts::AppInvocation; +pub use facts::InvocationType; +pub use facts::SkillInvocation; +pub use facts::SubAgentThreadStartedInput; +pub use facts::TrackEventsContext; +pub use facts::build_track_events_context; + +#[cfg(test)] +mod analytics_client_tests; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs new file mode 100644 index 0000000000..63b9c3d5be --- /dev/null +++ b/codex-rs/analytics/src/reducer.rs @@ -0,0 +1,320 @@ +use crate::events::AppServerRpcTransport; +use crate::events::CodexAppMentionedEventRequest; +use crate::events::CodexAppServerClientMetadata; +use crate::events::CodexAppUsedEventRequest; +use crate::events::CodexPluginEventRequest; +use crate::events::CodexPluginUsedEventRequest; +use crate::events::CodexRuntimeMetadata; +use crate::events::SkillInvocationEventParams; +use crate::events::SkillInvocationEventRequest; +use crate::events::ThreadInitializationMode; +use crate::events::ThreadInitializedEvent; +use crate::events::ThreadInitializedEventParams; +use crate::events::TrackEventRequest; +use crate::events::codex_app_metadata; +use crate::events::codex_plugin_metadata; +use crate::events::codex_plugin_used_metadata; +use crate::events::plugin_state_event_type; +use crate::events::subagent_thread_started_event_request; +use crate::events::thread_source_name; +use crate::facts::AnalyticsFact; +use crate::facts::AppMentionedInput; +use crate::facts::AppUsedInput; +use crate::facts::CustomAnalyticsFact; +use crate::facts::PluginState; +use crate::facts::PluginStateChangedInput; +use crate::facts::PluginUsedInput; +use crate::facts::SkillInvokedInput; +use crate::facts::SubAgentThreadStartedInput; +use codex_app_server_protocol::ClientResponse; +use codex_app_server_protocol::InitializeParams; +use codex_git_utils::collect_git_info; +use codex_git_utils::get_git_repo_root; +use codex_login::default_client::originator; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SkillScope; +use sha1::Digest; +use std::collections::HashMap; +use std::path::Path; + +#[derive(Default)] +pub(crate) struct AnalyticsReducer { + connections: HashMap, +} + +struct ConnectionState { + app_server_client: CodexAppServerClientMetadata, + runtime: CodexRuntimeMetadata, +} + +impl AnalyticsReducer { + pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { + match input { + AnalyticsFact::Initialize { + connection_id, + params, + product_client_id, + runtime, + rpc_transport, + } => { + self.ingest_initialize( + connection_id, + params, + product_client_id, + runtime, + rpc_transport, + ); + } + AnalyticsFact::Request { + connection_id: _connection_id, + request_id: _request_id, + request: _request, + } => {} + AnalyticsFact::Response { + connection_id, + response, + } => { + self.ingest_response(connection_id, *response, out); + } + AnalyticsFact::Notification(_notification) => {} + AnalyticsFact::Custom(input) => match input { + CustomAnalyticsFact::SubAgentThreadStarted(input) => { + self.ingest_subagent_thread_started(input, out); + } + CustomAnalyticsFact::SkillInvoked(input) => { + self.ingest_skill_invoked(input, out).await; + } + CustomAnalyticsFact::AppMentioned(input) => { + self.ingest_app_mentioned(input, out); + } + CustomAnalyticsFact::AppUsed(input) => { + self.ingest_app_used(input, out); + } + CustomAnalyticsFact::PluginUsed(input) => { + self.ingest_plugin_used(input, out); + } + CustomAnalyticsFact::PluginStateChanged(input) => { + self.ingest_plugin_state_changed(input, out); + } + }, + } + } + + fn ingest_initialize( + &mut self, + connection_id: u64, + params: InitializeParams, + product_client_id: String, + runtime: CodexRuntimeMetadata, + rpc_transport: AppServerRpcTransport, + ) { + self.connections.insert( + connection_id, + ConnectionState { + app_server_client: CodexAppServerClientMetadata { + product_client_id, + client_name: Some(params.client_info.name), + client_version: Some(params.client_info.version), + rpc_transport, + experimental_api_enabled: params + .capabilities + .map(|capabilities| capabilities.experimental_api), + }, + runtime, + }, + ); + } + + fn ingest_subagent_thread_started( + &mut self, + input: SubAgentThreadStartedInput, + out: &mut Vec, + ) { + out.push(TrackEventRequest::ThreadInitialized( + subagent_thread_started_event_request(input), + )); + } + + async fn ingest_skill_invoked( + &mut self, + input: SkillInvokedInput, + out: &mut Vec, + ) { + let SkillInvokedInput { + tracking, + invocations, + } = input; + for invocation in invocations { + let skill_scope = match invocation.skill_scope { + SkillScope::User => "user", + SkillScope::Repo => "repo", + SkillScope::System => "system", + SkillScope::Admin => "admin", + }; + let repo_root = get_git_repo_root(invocation.skill_path.as_path()); + let repo_url = if let Some(root) = repo_root.as_ref() { + collect_git_info(root) + .await + .and_then(|info| info.repository_url) + } else { + None + }; + let skill_id = skill_id_for_local_skill( + repo_url.as_deref(), + repo_root.as_deref(), + invocation.skill_path.as_path(), + invocation.skill_name.as_str(), + ); + out.push(TrackEventRequest::SkillInvocation( + SkillInvocationEventRequest { + event_type: "skill_invocation", + skill_id, + skill_name: invocation.skill_name.clone(), + event_params: SkillInvocationEventParams { + thread_id: Some(tracking.thread_id.clone()), + invoke_type: Some(invocation.invocation_type), + model_slug: Some(tracking.model_slug.clone()), + product_client_id: Some(originator().value), + repo_url, + skill_scope: Some(skill_scope.to_string()), + }, + }, + )); + } + } + + fn ingest_app_mentioned(&mut self, input: AppMentionedInput, out: &mut Vec) { + let AppMentionedInput { tracking, mentions } = input; + out.extend(mentions.into_iter().map(|mention| { + let event_params = codex_app_metadata(&tracking, mention); + TrackEventRequest::AppMentioned(CodexAppMentionedEventRequest { + event_type: "codex_app_mentioned", + event_params, + }) + })); + } + + fn ingest_app_used(&mut self, input: AppUsedInput, out: &mut Vec) { + let AppUsedInput { tracking, app } = input; + let event_params = codex_app_metadata(&tracking, app); + out.push(TrackEventRequest::AppUsed(CodexAppUsedEventRequest { + event_type: "codex_app_used", + event_params, + })); + } + + fn ingest_plugin_used(&mut self, input: PluginUsedInput, out: &mut Vec) { + let PluginUsedInput { tracking, plugin } = input; + out.push(TrackEventRequest::PluginUsed(CodexPluginUsedEventRequest { + event_type: "codex_plugin_used", + event_params: codex_plugin_used_metadata(&tracking, plugin), + })); + } + + fn ingest_plugin_state_changed( + &mut self, + input: PluginStateChangedInput, + out: &mut Vec, + ) { + let PluginStateChangedInput { plugin, state } = input; + let event = CodexPluginEventRequest { + event_type: plugin_state_event_type(state), + event_params: codex_plugin_metadata(plugin), + }; + out.push(match state { + PluginState::Installed => TrackEventRequest::PluginInstalled(event), + PluginState::Uninstalled => TrackEventRequest::PluginUninstalled(event), + PluginState::Enabled => TrackEventRequest::PluginEnabled(event), + PluginState::Disabled => TrackEventRequest::PluginDisabled(event), + }); + } + + fn ingest_response( + &mut self, + connection_id: u64, + response: ClientResponse, + out: &mut Vec, + ) { + let (thread, model, initialization_mode) = match response { + ClientResponse::ThreadStart { response, .. } => ( + response.thread, + response.model, + ThreadInitializationMode::New, + ), + ClientResponse::ThreadResume { response, .. } => ( + response.thread, + response.model, + ThreadInitializationMode::Resumed, + ), + ClientResponse::ThreadFork { response, .. } => ( + response.thread, + response.model, + ThreadInitializationMode::Forked, + ), + _ => return, + }; + let thread_source: SessionSource = thread.source.into(); + let Some(connection_state) = self.connections.get(&connection_id) else { + return; + }; + out.push(TrackEventRequest::ThreadInitialized( + ThreadInitializedEvent { + event_type: "codex_thread_initialized", + event_params: ThreadInitializedEventParams { + thread_id: thread.id, + app_server_client: connection_state.app_server_client.clone(), + runtime: connection_state.runtime.clone(), + model, + ephemeral: thread.ephemeral, + thread_source: thread_source_name(&thread_source), + initialization_mode, + subagent_source: None, + parent_thread_id: None, + created_at: u64::try_from(thread.created_at).unwrap_or_default(), + }, + }, + )); + } +} + +pub(crate) fn skill_id_for_local_skill( + repo_url: Option<&str>, + repo_root: Option<&Path>, + skill_path: &Path, + skill_name: &str, +) -> String { + let path = normalize_path_for_skill_id(repo_url, repo_root, skill_path); + let prefix = if let Some(url) = repo_url { + format!("repo_{url}") + } else { + "personal".to_string() + }; + let raw_id = format!("{prefix}_{path}_{skill_name}"); + let mut hasher = sha1::Sha1::new(); + sha1::Digest::update(&mut hasher, raw_id.as_bytes()); + format!("{:x}", sha1::Digest::finalize(hasher)) +} + +/// Returns a normalized path for skill ID construction. +/// +/// - Repo-scoped skills use a path relative to the repo root. +/// - User/admin/system skills use an absolute path. +pub(crate) fn normalize_path_for_skill_id( + repo_url: Option<&str>, + repo_root: Option<&Path>, + skill_path: &Path, +) -> String { + let resolved_path = + std::fs::canonicalize(skill_path).unwrap_or_else(|_| skill_path.to_path_buf()); + match (repo_url, repo_root) { + (Some(_), Some(root)) => { + let resolved_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + resolved_path + .strip_prefix(&resolved_root) + .unwrap_or(resolved_path.as_path()) + .to_string_lossy() + .replace('\\', "/") + } + _ => resolved_path.to_string_lossy().replace('\\', "/"), + } +} diff --git a/codex-rs/ansi-escape/Cargo.toml b/codex-rs/ansi-escape/Cargo.toml index a10dbf9134..3ebad2bdef 100644 --- a/codex-rs/ansi-escape/Cargo.toml +++ b/codex-rs/ansi-escape/Cargo.toml @@ -8,6 +8,9 @@ license.workspace = true name = "codex_ansi_escape" path = "src/lib.rs" +[lints] +workspace = true + [dependencies] ansi-to-tui = { workspace = true } ratatui = { workspace = true, features = [ diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index c20b08f270..933e24e9ab 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -868,8 +868,11 @@ mod tests { use tokio::net::TcpListener; use tokio::time::Duration; use tokio::time::timeout; - use tokio_tungstenite::accept_async; + use tokio_tungstenite::accept_hdr_async; use tokio_tungstenite::tungstenite::Message; + use tokio_tungstenite::tungstenite::handshake::server::Request as WebSocketRequest; + use tokio_tungstenite::tungstenite::handshake::server::Response as WebSocketResponse; + use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; async fn build_test_config() -> Config { match ConfigBuilder::default().build().await { @@ -909,6 +912,19 @@ mod tests { } async fn start_test_remote_server(handler: F) -> String + where + F: FnOnce(tokio_tungstenite::WebSocketStream) -> Fut + + Send + + 'static, + Fut: std::future::Future + Send + 'static, + { + start_test_remote_server_with_auth(/*expected_auth_token*/ None, handler).await + } + + async fn start_test_remote_server_with_auth( + expected_auth_token: Option, + handler: F, + ) -> String where F: FnOnce(tokio_tungstenite::WebSocketStream) -> Fut + Send @@ -921,9 +937,23 @@ mod tests { let addr = listener.local_addr().expect("listener address"); tokio::spawn(async move { let (stream, _) = listener.accept().await.expect("accept should succeed"); - let websocket = accept_async(stream) - .await - .expect("websocket upgrade should succeed"); + let websocket = accept_hdr_async( + stream, + move |request: &WebSocketRequest, response: WebSocketResponse| { + let provided_auth_token = request + .headers() + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let expected_auth_token = expected_auth_token + .as_ref() + .map(|token| format!("Bearer {token}")); + assert_eq!(provided_auth_token, expected_auth_token); + Ok(response) + }, + ) + .await + .expect("websocket upgrade should succeed"); handler(websocket).await; }); format!("ws://{addr}") @@ -1031,6 +1061,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: Some(1), }, }) } @@ -1038,6 +1071,7 @@ mod tests { fn test_remote_connect_args(websocket_url: String) -> RemoteAppServerConnectArgs { RemoteAppServerConnectArgs { websocket_url, + auth_token: None, client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, @@ -1134,7 +1168,8 @@ mod tests { #[tokio::test] async fn tiny_channel_capacity_still_supports_request_roundtrip() { - let client = start_test_client_with_capacity(SessionSource::Exec, 1).await; + let client = + start_test_client_with_capacity(SessionSource::Exec, /*channel_capacity*/ 1).await; let _response: ConfigRequirementsReadResponse = client .request_typed(ClientRequest::ConfigRequirementsRead { request_id: RequestId::Integer(1), @@ -1254,6 +1289,7 @@ mod tests { }), ) .await; + websocket.close(None).await.expect("close should succeed"); }) .await; let client = RemoteAppServerClient::connect(test_remote_connect_args(websocket_url)) @@ -1274,6 +1310,59 @@ mod tests { client.shutdown().await.expect("shutdown should complete"); } + #[tokio::test] + async fn remote_connect_includes_auth_header_when_configured() { + let auth_token = "remote-bearer-token".to_string(); + let websocket_url = start_test_remote_server_with_auth( + Some(auth_token.clone()), + |mut websocket| async move { + expect_remote_initialize(&mut websocket).await; + websocket.close(None).await.expect("close should succeed"); + }, + ) + .await; + let client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { + auth_token: Some(auth_token), + ..test_remote_connect_args(websocket_url) + }) + .await + .expect("remote client should connect"); + + client.shutdown().await.expect("shutdown should complete"); + } + + #[tokio::test] + async fn remote_connect_rejects_non_loopback_ws_when_auth_configured() { + let result = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { + websocket_url: "ws://example.com:4500".to_string(), + auth_token: Some("remote-bearer-token".to_string()), + ..test_remote_connect_args("ws://127.0.0.1:1".to_string()) + }) + .await; + let err = match result { + Ok(_) => panic!("non-loopback ws should be rejected before connect"), + Err(err) => err, + }; + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert!( + err.to_string() + .contains("remote auth tokens require `wss://` or loopback `ws://` URLs") + ); + } + + #[test] + fn remote_auth_token_transport_policy_allows_wss_and_loopback_ws() { + assert!(crate::remote::websocket_url_supports_auth_token( + &url::Url::parse("wss://example.com:443").expect("wss URL should parse") + )); + assert!(crate::remote::websocket_url_supports_auth_token( + &url::Url::parse("ws://127.0.0.1:4500").expect("loopback ws URL should parse") + )); + assert!(!crate::remote::websocket_url_supports_auth_token( + &url::Url::parse("ws://example.com:4500").expect("non-loopback ws URL should parse") + )); + } + #[tokio::test] async fn remote_duplicate_request_id_keeps_original_waiter() { let (first_request_seen_tx, first_request_seen_rx) = tokio::sync::oneshot::channel(); @@ -1426,6 +1515,7 @@ mod tests { .await; let mut client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { websocket_url, + auth_token: None, client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, @@ -1748,6 +1838,9 @@ mod tests { items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, } ) diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index 9cf37e262f..c3b22c575d 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -4,9 +4,8 @@ This module implements the websocket-backed app-server client transport. It owns the remote connection lifecycle, including the initialize/initialized handshake, JSON-RPC request/response routing, server-request resolution, and notification streaming. The rest of the crate uses the same `AppServerEvent` -surface for both in-process and remote transports, so callers such as -`tui_app_server` can switch between them without changing their higher-level -session logic. +surface for both in-process and remote transports, so callers such as the TUI +can switch between them without changing their higher-level session logic. */ use std::collections::HashMap; @@ -48,6 +47,9 @@ use tokio_tungstenite::MaybeTlsStream; use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::HeaderValue; +use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; use tracing::warn; use url::Url; @@ -57,6 +59,7 @@ const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(10); #[derive(Debug, Clone)] pub struct RemoteAppServerConnectArgs { pub websocket_url: String, + pub auth_token: Option, pub client_name: String, pub client_version: String, pub experimental_api: bool, @@ -86,6 +89,16 @@ impl RemoteAppServerConnectArgs { } } +pub(crate) fn websocket_url_supports_auth_token(url: &Url) -> bool { + match (url.scheme(), url.host()) { + ("wss", Some(_)) => true, + ("ws", Some(url::Host::Domain(domain))) => domain.eq_ignore_ascii_case("localhost"), + ("ws", Some(url::Host::Ipv4(addr))) => addr.is_loopback(), + ("ws", Some(url::Host::Ipv6(addr))) => addr.is_loopback(), + _ => false, + } +} + enum RemoteClientCommand { Request { request: Box, @@ -132,7 +145,31 @@ impl RemoteAppServerClient { format!("invalid websocket URL `{websocket_url}`: {err}"), ) })?; - let stream = timeout(CONNECT_TIMEOUT, connect_async(url.as_str())) + if args.auth_token.is_some() && !websocket_url_supports_auth_token(&url) { + return Err(IoError::new( + ErrorKind::InvalidInput, + format!( + "remote auth tokens require `wss://` or loopback `ws://` URLs; got `{websocket_url}`" + ), + )); + } + let mut request = url.as_str().into_client_request().map_err(|err| { + IoError::new( + ErrorKind::InvalidInput, + format!("invalid websocket URL `{websocket_url}`: {err}"), + ) + })?; + if let Some(auth_token) = args.auth_token.as_deref() { + let header_value = + HeaderValue::from_str(&format!("Bearer {auth_token}")).map_err(|err| { + IoError::new( + ErrorKind::InvalidInput, + format!("invalid remote authorization header value: {err}"), + ) + })?; + request.headers_mut().insert(AUTHORIZATION, header_value); + } + let stream = timeout(CONNECT_TIMEOUT, connect_async(request)) .await .map_err(|_| { IoError::new( diff --git a/codex-rs/app-server-protocol/Cargo.toml b/codex-rs/app-server-protocol/Cargo.toml index a9d90831ef..d9ed5e8730 100644 --- a/codex-rs/app-server-protocol/Cargo.toml +++ b/codex-rs/app-server-protocol/Cargo.toml @@ -17,12 +17,12 @@ clap = { workspace = true, features = ["derive"] } codex-experimental-api-macros = { workspace = true } codex-git-utils = { workspace = true } codex-protocol = { workspace = true } +codex-shell-command = { workspace = true } codex-utils-absolute-path = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_with = { workspace = true } -shlex = { workspace = true } strum_macros = { workspace = true } thiserror = { workspace = true } rmcp = { workspace = true, default-features = false, features = [ diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 9227a97d55..5039a0bfe7 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -800,7 +800,7 @@ "description": "Stop filesystem watch notifications for a prior `fs/watch`.", "properties": { "watchId": { - "description": "Watch identifier returned by `fs/watch`.", + "description": "Watch identifier previously provided to `fs/watch`.", "type": "string" } }, @@ -819,10 +819,15 @@ } ], "description": "Absolute file or directory path to watch." + }, + "watchId": { + "description": "Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.", + "type": "string" } }, "required": [ - "path" + "path", + "watchId" ], "type": "object" }, @@ -1042,6 +1047,17 @@ "null" ] }, + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/McpServerStatusDetail" + }, + { + "type": "null" + } + ], + "description": "Controls how much MCP inventory data to fetch for each server. Defaults to `Full` when omitted." + }, "limit": { "description": "Optional page size; defaults to a server-defined value.", "format": "uint32", @@ -1156,6 +1172,22 @@ "title": "ChatgptLoginAccountParams", "type": "object" }, + { + "properties": { + "type": { + "enum": [ + "chatgptDeviceCode" + ], + "title": "ChatgptDeviceCodeLoginAccountParamsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ChatgptDeviceCodeLoginAccountParams", + "type": "object" + }, { "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have.", "properties": { @@ -1192,6 +1224,25 @@ } ] }, + "McpResourceReadParams": { + "properties": { + "server": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": [ + "server", + "threadId", + "uri" + ], + "type": "object" + }, "McpServerOauthLoginParams": { "properties": { "name": { @@ -1219,6 +1270,13 @@ ], "type": "object" }, + "McpServerStatusDetail": { + "enum": [ + "full", + "toolsAndAuthOnly" + ], + "type": "string" + }, "MergeStrategy": { "enum": [ "replace", @@ -4404,6 +4462,30 @@ "title": "McpServerStatus/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "mcpServer/resource/read" + ], + "title": "McpServer/resource/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/McpResourceReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "McpServer/resource/readRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json index 2c146b9522..617fa1f3cb 100644 --- a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json @@ -28,41 +28,6 @@ }, "type": "object" }, - "AdditionalMacOsPermissions": { - "properties": { - "accessibility": { - "type": "boolean" - }, - "automations": { - "$ref": "#/definitions/MacOsAutomationPermission" - }, - "calendar": { - "type": "boolean" - }, - "contacts": { - "$ref": "#/definitions/MacOsContactsPermission" - }, - "launchServices": { - "type": "boolean" - }, - "preferences": { - "$ref": "#/definitions/MacOsPreferencesPermission" - }, - "reminders": { - "type": "boolean" - } - }, - "required": [ - "accessibility", - "automations", - "calendar", - "contacts", - "launchServices", - "preferences", - "reminders" - ], - "type": "object" - }, "AdditionalNetworkPermissions": { "properties": { "enabled": { @@ -86,16 +51,6 @@ } ] }, - "macos": { - "anyOf": [ - { - "$ref": "#/definitions/AdditionalMacOsPermissions" - }, - { - "type": "null" - } - ] - }, "network": { "anyOf": [ { @@ -298,60 +253,6 @@ } ] }, - "CommandExecutionRequestApprovalSkillMetadata": { - "properties": { - "pathToSkillsMd": { - "type": "string" - } - }, - "required": [ - "pathToSkillsMd" - ], - "type": "object" - }, - "MacOsAutomationPermission": { - "oneOf": [ - { - "enum": [ - "none", - "all" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "bundle_ids": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "bundle_ids" - ], - "title": "BundleIdsMacOsAutomationPermission", - "type": "object" - } - ] - }, - "MacOsContactsPermission": { - "enum": [ - "none", - "read_only", - "read_write" - ], - "type": "string" - }, - "MacOsPreferencesPermission": { - "enum": [ - "none", - "read_only", - "read_write" - ], - "type": "string" - }, "NetworkApprovalContext": { "properties": { "host": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 37f7ff8c5f..d550f523ce 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1013,7 +1013,7 @@ "type": "array" }, "watchId": { - "description": "Watch identifier returned by `fs/watch`.", + "description": "Watch identifier previously provided to `fs/watch`.", "type": "string" } }, @@ -1163,6 +1163,176 @@ ], "type": "object" }, + "GuardianApprovalReviewAction": { + "oneOf": [ + { + "properties": { + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/GuardianCommandSource" + }, + "type": { + "enum": [ + "command" + ], + "title": "CommandGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "command", + "cwd", + "source", + "type" + ], + "title": "CommandGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "argv": { + "items": { + "type": "string" + }, + "type": "array" + }, + "cwd": { + "type": "string" + }, + "program": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/GuardianCommandSource" + }, + "type": { + "enum": [ + "execve" + ], + "title": "ExecveGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "argv", + "cwd", + "program", + "source", + "type" + ], + "title": "ExecveGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "cwd": { + "type": "string" + }, + "files": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "enum": [ + "applyPatch" + ], + "title": "ApplyPatchGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "cwd", + "files", + "type" + ], + "title": "ApplyPatchGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "host": { + "type": "string" + }, + "port": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + }, + "target": { + "type": "string" + }, + "type": { + "enum": [ + "networkAccess" + ], + "title": "NetworkAccessGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "host", + "port", + "protocol", + "target", + "type" + ], + "title": "NetworkAccessGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "connectorId": { + "type": [ + "string", + "null" + ] + }, + "connectorName": { + "type": [ + "string", + "null" + ] + }, + "server": { + "type": "string" + }, + "toolName": { + "type": "string" + }, + "toolTitle": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "mcpToolCall" + ], + "title": "McpToolCallGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "server", + "toolName", + "type" + ], + "title": "McpToolCallGuardianApprovalReviewAction", + "type": "object" + } + ] + }, "GuardianApprovalReviewStatus": { "description": "[UNSTABLE] Lifecycle state for a guardian approval review.", "enum": [ @@ -1173,6 +1343,13 @@ ], "type": "string" }, + "GuardianCommandSource": { + "enum": [ + "shell", + "unifiedExec" + ], + "type": "string" + }, "GuardianRiskLevel": { "description": "[UNSTABLE] Risk level assigned by guardian approval review.", "enum": [ @@ -1206,6 +1383,7 @@ "HookEventName": { "enum": [ "preToolUse", + "postToolUse", "sessionStart", "userPromptSubmit", "stop" @@ -1399,7 +1577,9 @@ "ItemGuardianApprovalReviewCompletedNotification": { "description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.\n\nTODO(ccunningham): Attach guardian review state to the reviewed tool item's lifecycle instead of sending separate standalone review notifications so the app-server API can persist and replay review state via `thread/read`.", "properties": { - "action": true, + "action": { + "$ref": "#/definitions/GuardianApprovalReviewAction" + }, "review": { "$ref": "#/definitions/GuardianApprovalReview" }, @@ -1414,6 +1594,7 @@ } }, "required": [ + "action", "review", "targetItemId", "threadId", @@ -1424,7 +1605,9 @@ "ItemGuardianApprovalReviewStartedNotification": { "description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.\n\nTODO(ccunningham): Attach guardian review state to the reviewed tool item's lifecycle instead of sending separate standalone review notifications so the app-server API can persist and replay review state via `thread/read`.", "properties": { - "action": true, + "action": { + "$ref": "#/definitions/GuardianApprovalReviewAction" + }, "review": { "$ref": "#/definitions/GuardianApprovalReview" }, @@ -1439,6 +1622,7 @@ } }, "required": [ + "action", "review", "targetItemId", "threadId", @@ -1552,6 +1736,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -1671,6 +1856,15 @@ ], "type": "object" }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5Tcp", + "socks5Udp" + ], + "type": "string" + }, "NonSteerableTurnKind": { "enum": [ "review", @@ -1776,7 +1970,9 @@ "plus", "pro", "team", + "self_serve_business_usage_based", "business", + "enterprise_cbp_usage_based", "enterprise", "edu", "unknown" @@ -2230,6 +2426,13 @@ "description": "Whether the thread is ephemeral and should not be materialized on disk.", "type": "boolean" }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, "gitInfo": { "anyOf": [ { @@ -3330,6 +3533,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -3351,6 +3570,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 1fbbfb1b0a..7c11a4c02b 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -28,41 +28,6 @@ }, "type": "object" }, - "AdditionalMacOsPermissions": { - "properties": { - "accessibility": { - "type": "boolean" - }, - "automations": { - "$ref": "#/definitions/MacOsAutomationPermission" - }, - "calendar": { - "type": "boolean" - }, - "contacts": { - "$ref": "#/definitions/MacOsContactsPermission" - }, - "launchServices": { - "type": "boolean" - }, - "preferences": { - "$ref": "#/definitions/MacOsPreferencesPermission" - }, - "reminders": { - "type": "boolean" - } - }, - "required": [ - "accessibility", - "automations", - "calendar", - "contacts", - "launchServices", - "preferences", - "reminders" - ], - "type": "object" - }, "AdditionalNetworkPermissions": { "properties": { "enabled": { @@ -86,16 +51,6 @@ } ] }, - "macos": { - "anyOf": [ - { - "$ref": "#/definitions/AdditionalMacOsPermissions" - }, - { - "type": "null" - } - ] - }, "network": { "anyOf": [ { @@ -452,17 +407,6 @@ ], "type": "object" }, - "CommandExecutionRequestApprovalSkillMetadata": { - "properties": { - "pathToSkillsMd": { - "type": "string" - } - }, - "required": [ - "pathToSkillsMd" - ], - "type": "object" - }, "DynamicToolCallParams": { "properties": { "arguments": true, @@ -638,49 +582,6 @@ ], "type": "object" }, - "MacOsAutomationPermission": { - "oneOf": [ - { - "enum": [ - "none", - "all" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "bundle_ids": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "bundle_ids" - ], - "title": "BundleIdsMacOsAutomationPermission", - "type": "object" - } - ] - }, - "MacOsContactsPermission": { - "enum": [ - "none", - "read_only", - "read_write" - ], - "type": "string" - }, - "MacOsPreferencesPermission": { - "enum": [ - "none", - "read_only", - "read_write" - ], - "type": "string" - }, "McpElicitationArrayType": { "enum": [ "array" 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 d4269f5239..da21699875 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 @@ -28,41 +28,6 @@ }, "type": "object" }, - "AdditionalMacOsPermissions": { - "properties": { - "accessibility": { - "type": "boolean" - }, - "automations": { - "$ref": "#/definitions/MacOsAutomationPermission" - }, - "calendar": { - "type": "boolean" - }, - "contacts": { - "$ref": "#/definitions/MacOsContactsPermission" - }, - "launchServices": { - "type": "boolean" - }, - "preferences": { - "$ref": "#/definitions/MacOsPreferencesPermission" - }, - "reminders": { - "type": "boolean" - } - }, - "required": [ - "accessibility", - "automations", - "calendar", - "contacts", - "launchServices", - "preferences", - "reminders" - ], - "type": "object" - }, "AdditionalNetworkPermissions": { "properties": { "enabled": { @@ -86,16 +51,6 @@ } ] }, - "macos": { - "anyOf": [ - { - "$ref": "#/definitions/AdditionalMacOsPermissions" - }, - { - "type": "null" - } - ] - }, "network": { "anyOf": [ { @@ -1246,6 +1201,30 @@ "title": "McpServerStatus/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "mcpServer/resource/read" + ], + "title": "McpServer/resource/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/McpResourceReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "McpServer/resource/readRequest", + "type": "object" + }, { "properties": { "id": { @@ -1864,17 +1843,6 @@ "title": "CommandExecutionRequestApprovalResponse", "type": "object" }, - "CommandExecutionRequestApprovalSkillMetadata": { - "properties": { - "pathToSkillsMd": { - "type": "string" - } - }, - "required": [ - "pathToSkillsMd" - ], - "type": "object" - }, "DynamicToolCallParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -2479,49 +2447,6 @@ "title": "JSONRPCResponse", "type": "object" }, - "MacOsAutomationPermission": { - "oneOf": [ - { - "enum": [ - "none", - "all" - ], - "type": "string" - }, - { - "additionalProperties": false, - "properties": { - "bundle_ids": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "bundle_ids" - ], - "title": "BundleIdsMacOsAutomationPermission", - "type": "object" - } - ] - }, - "MacOsContactsPermission": { - "enum": [ - "none", - "read_only", - "read_write" - ], - "type": "string" - }, - "MacOsPreferencesPermission": { - "enum": [ - "none", - "read_only", - "read_write" - ], - "type": "string" - }, "McpElicitationArrayType": { "enum": [ "array" @@ -3162,7 +3087,7 @@ "type": "string" }, "protocol": { - "$ref": "#/definitions/NetworkApprovalProtocol" + "$ref": "#/definitions/v2/NetworkApprovalProtocol" } }, "required": [ @@ -3171,15 +3096,6 @@ ], "type": "object" }, - "NetworkApprovalProtocol": { - "enum": [ - "http", - "https", - "socks5Tcp", - "socks5Udp" - ], - "type": "string" - }, "NetworkPolicyAmendment": { "properties": { "action": { @@ -7595,7 +7511,7 @@ "type": "array" }, "watchId": { - "description": "Watch identifier returned by `fs/watch`.", + "description": "Watch identifier previously provided to `fs/watch`.", "type": "string" } }, @@ -7865,7 +7781,7 @@ "description": "Stop filesystem watch notifications for a prior `fs/watch`.", "properties": { "watchId": { - "description": "Watch identifier returned by `fs/watch`.", + "description": "Watch identifier previously provided to `fs/watch`.", "type": "string" } }, @@ -7892,25 +7808,6 @@ } ], "description": "Absolute file or directory path to watch." - } - }, - "required": [ - "path" - ], - "title": "FsWatchParams", - "type": "object" - }, - "FsWatchResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Created watch handle returned by `fs/watch`.", - "properties": { - "path": { - "allOf": [ - { - "$ref": "#/definitions/v2/AbsolutePathBuf" - } - ], - "description": "Canonicalized path associated with the watch." }, "watchId": { "description": "Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.", @@ -7921,6 +7818,25 @@ "path", "watchId" ], + "title": "FsWatchParams", + "type": "object" + }, + "FsWatchResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful response for `fs/watch`.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + } + ], + "description": "Canonicalized path associated with the watch." + } + }, + "required": [ + "path" + ], "title": "FsWatchResponse", "type": "object" }, @@ -8176,6 +8092,176 @@ ], "type": "object" }, + "GuardianApprovalReviewAction": { + "oneOf": [ + { + "properties": { + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/v2/GuardianCommandSource" + }, + "type": { + "enum": [ + "command" + ], + "title": "CommandGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "command", + "cwd", + "source", + "type" + ], + "title": "CommandGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "argv": { + "items": { + "type": "string" + }, + "type": "array" + }, + "cwd": { + "type": "string" + }, + "program": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/v2/GuardianCommandSource" + }, + "type": { + "enum": [ + "execve" + ], + "title": "ExecveGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "argv", + "cwd", + "program", + "source", + "type" + ], + "title": "ExecveGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "cwd": { + "type": "string" + }, + "files": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "enum": [ + "applyPatch" + ], + "title": "ApplyPatchGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "cwd", + "files", + "type" + ], + "title": "ApplyPatchGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "host": { + "type": "string" + }, + "port": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "protocol": { + "$ref": "#/definitions/v2/NetworkApprovalProtocol" + }, + "target": { + "type": "string" + }, + "type": { + "enum": [ + "networkAccess" + ], + "title": "NetworkAccessGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "host", + "port", + "protocol", + "target", + "type" + ], + "title": "NetworkAccessGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "connectorId": { + "type": [ + "string", + "null" + ] + }, + "connectorName": { + "type": [ + "string", + "null" + ] + }, + "server": { + "type": "string" + }, + "toolName": { + "type": "string" + }, + "toolTitle": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "mcpToolCall" + ], + "title": "McpToolCallGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "server", + "toolName", + "type" + ], + "title": "McpToolCallGuardianApprovalReviewAction", + "type": "object" + } + ] + }, "GuardianApprovalReviewStatus": { "description": "[UNSTABLE] Lifecycle state for a guardian approval review.", "enum": [ @@ -8186,6 +8272,13 @@ ], "type": "string" }, + "GuardianCommandSource": { + "enum": [ + "shell", + "unifiedExec" + ], + "type": "string" + }, "GuardianRiskLevel": { "description": "[UNSTABLE] Risk level assigned by guardian approval review.", "enum": [ @@ -8221,6 +8314,7 @@ "HookEventName": { "enum": [ "preToolUse", + "postToolUse", "sessionStart", "userPromptSubmit", "stop" @@ -8447,7 +8541,9 @@ "$schema": "http://json-schema.org/draft-07/schema#", "description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.\n\nTODO(ccunningham): Attach guardian review state to the reviewed tool item's lifecycle instead of sending separate standalone review notifications so the app-server API can persist and replay review state via `thread/read`.", "properties": { - "action": true, + "action": { + "$ref": "#/definitions/v2/GuardianApprovalReviewAction" + }, "review": { "$ref": "#/definitions/v2/GuardianApprovalReview" }, @@ -8462,6 +8558,7 @@ } }, "required": [ + "action", "review", "targetItemId", "threadId", @@ -8474,7 +8571,9 @@ "$schema": "http://json-schema.org/draft-07/schema#", "description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.\n\nTODO(ccunningham): Attach guardian review state to the reviewed tool item's lifecycle instead of sending separate standalone review notifications so the app-server API can persist and replay review state via `thread/read`.", "properties": { - "action": true, + "action": { + "$ref": "#/definitions/v2/GuardianApprovalReviewAction" + }, "review": { "$ref": "#/definitions/v2/GuardianApprovalReview" }, @@ -8489,6 +8588,7 @@ } }, "required": [ + "action", "review", "targetItemId", "threadId", @@ -8528,6 +8628,17 @@ "null" ] }, + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/v2/McpServerStatusDetail" + }, + { + "type": "null" + } + ], + "description": "Controls how much MCP inventory data to fetch for each server. Defaults to `Full` when omitted." + }, "limit": { "description": "Optional page size; defaults to a server-defined value.", "format": "uint32", @@ -8667,6 +8778,22 @@ "title": "Chatgptv2::LoginAccountParams", "type": "object" }, + { + "properties": { + "type": { + "enum": [ + "chatgptDeviceCode" + ], + "title": "ChatgptDeviceCodev2::LoginAccountParamsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ChatgptDeviceCodev2::LoginAccountParams", + "type": "object" + }, { "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have.", "properties": { @@ -8748,6 +8875,36 @@ "title": "Chatgptv2::LoginAccountResponse", "type": "object" }, + { + "properties": { + "loginId": { + "type": "string" + }, + "type": { + "enum": [ + "chatgptDeviceCode" + ], + "title": "ChatgptDeviceCodev2::LoginAccountResponseType", + "type": "string" + }, + "userCode": { + "description": "One-time code the user must enter after signing in.", + "type": "string" + }, + "verificationUrl": { + "description": "URL the client should open in a browser to complete device code authorization.", + "type": "string" + } + }, + "required": [ + "loginId", + "type", + "userCode", + "verificationUrl" + ], + "title": "ChatgptDeviceCodev2::LoginAccountResponse", + "type": "object" + }, { "properties": { "type": { @@ -8807,6 +8964,43 @@ ], "type": "string" }, + "McpResourceReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "server": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": [ + "server", + "threadId", + "uri" + ], + "title": "McpResourceReadParams", + "type": "object" + }, + "McpResourceReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "contents": { + "items": { + "$ref": "#/definitions/v2/ResourceContent" + }, + "type": "array" + } + }, + "required": [ + "contents" + ], + "title": "McpResourceReadResponse", + "type": "object" + }, "McpServerOauthLoginCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -8922,6 +9116,13 @@ ], "type": "object" }, + "McpServerStatusDetail": { + "enum": [ + "full", + "toolsAndAuthOnly" + ], + "type": "string" + }, "McpServerStatusUpdatedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -8983,6 +9184,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -9301,6 +9503,22 @@ ], "type": "string" }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5Tcp", + "socks5Udp" + ], + "type": "string" + }, + "NetworkDomainPermission": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, "NetworkRequirements": { "properties": { "allowLocalBinding": { @@ -9310,6 +9528,7 @@ ] }, "allowUnixSockets": { + "description": "Legacy compatibility view derived from `unix_sockets`.", "items": { "type": "string" }, @@ -9325,6 +9544,7 @@ ] }, "allowedDomains": { + "description": "Legacy compatibility view derived from `domains`.", "items": { "type": "string" }, @@ -9333,6 +9553,12 @@ "null" ] }, + "dangerFullAccessDenylistOnly": { + "type": [ + "boolean", + "null" + ] + }, "dangerouslyAllowAllUnixSockets": { "type": [ "boolean", @@ -9346,6 +9572,7 @@ ] }, "deniedDomains": { + "description": "Legacy compatibility view derived from `domains`.", "items": { "type": "string" }, @@ -9354,6 +9581,16 @@ "null" ] }, + "domains": { + "additionalProperties": { + "$ref": "#/definitions/v2/NetworkDomainPermission" + }, + "description": "Canonical network permission map for `experimental_network`.", + "type": [ + "object", + "null" + ] + }, "enabled": { "type": [ "boolean", @@ -9368,6 +9605,13 @@ "null" ] }, + "managedAllowedDomainsOnly": { + "description": "When true, only managed allowlist entries are respected while managed network enforcement is active.", + "type": [ + "boolean", + "null" + ] + }, "socksPort": { "format": "uint16", "minimum": 0.0, @@ -9375,10 +9619,27 @@ "integer", "null" ] + }, + "unixSockets": { + "additionalProperties": { + "$ref": "#/definitions/v2/NetworkUnixSocketPermission" + }, + "description": "Canonical unix socket permission map for `experimental_network`.", + "type": [ + "object", + "null" + ] } }, "type": "object" }, + "NetworkUnixSocketPermission": { + "enum": [ + "allow", + "none" + ], + "type": "string" + }, "NonSteerableTurnKind": { "enum": [ "review", @@ -9511,7 +9772,9 @@ "plus", "pro", "team", + "self_serve_business_usage_based", "business", + "enterprise_cbp_usage_based", "enterprise", "edu", "unknown" @@ -10466,6 +10729,57 @@ ], "type": "object" }, + "ResourceContent": { + "anyOf": [ + { + "properties": { + "_meta": true, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "text": { + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "type": "string" + } + }, + "required": [ + "text", + "uri" + ], + "type": "object" + }, + { + "properties": { + "_meta": true, + "blob": { + "type": "string" + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "description": "The URI of this resource.", + "type": "string" + } + }, + "required": [ + "blob", + "uri" + ], + "type": "object" + } + ], + "description": "Contents returned when reading a resource from an MCP server." + }, "ResourceTemplate": { "description": "A template description for resources available on the server.", "properties": { @@ -12009,6 +12323,13 @@ "description": "Whether the thread is ephemeral and should not be materialized on disk.", "type": "boolean" }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, "gitInfo": { "anyOf": [ { @@ -14145,6 +14466,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -14166,6 +14503,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/v2/TurnStatus" } 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 acf36d6c3c..a66c34e14d 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 @@ -1776,6 +1776,30 @@ "title": "McpServerStatus/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "mcpServer/resource/read" + ], + "title": "McpServer/resource/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/McpResourceReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "McpServer/resource/readRequest", + "type": "object" + }, { "properties": { "id": { @@ -4155,7 +4179,7 @@ "type": "array" }, "watchId": { - "description": "Watch identifier returned by `fs/watch`.", + "description": "Watch identifier previously provided to `fs/watch`.", "type": "string" } }, @@ -4425,7 +4449,7 @@ "description": "Stop filesystem watch notifications for a prior `fs/watch`.", "properties": { "watchId": { - "description": "Watch identifier returned by `fs/watch`.", + "description": "Watch identifier previously provided to `fs/watch`.", "type": "string" } }, @@ -4452,25 +4476,6 @@ } ], "description": "Absolute file or directory path to watch." - } - }, - "required": [ - "path" - ], - "title": "FsWatchParams", - "type": "object" - }, - "FsWatchResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Created watch handle returned by `fs/watch`.", - "properties": { - "path": { - "allOf": [ - { - "$ref": "#/definitions/AbsolutePathBuf" - } - ], - "description": "Canonicalized path associated with the watch." }, "watchId": { "description": "Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.", @@ -4481,6 +4486,25 @@ "path", "watchId" ], + "title": "FsWatchParams", + "type": "object" + }, + "FsWatchResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful response for `fs/watch`.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Canonicalized path associated with the watch." + } + }, + "required": [ + "path" + ], "title": "FsWatchResponse", "type": "object" }, @@ -4847,6 +4871,176 @@ ], "type": "object" }, + "GuardianApprovalReviewAction": { + "oneOf": [ + { + "properties": { + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/GuardianCommandSource" + }, + "type": { + "enum": [ + "command" + ], + "title": "CommandGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "command", + "cwd", + "source", + "type" + ], + "title": "CommandGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "argv": { + "items": { + "type": "string" + }, + "type": "array" + }, + "cwd": { + "type": "string" + }, + "program": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/GuardianCommandSource" + }, + "type": { + "enum": [ + "execve" + ], + "title": "ExecveGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "argv", + "cwd", + "program", + "source", + "type" + ], + "title": "ExecveGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "cwd": { + "type": "string" + }, + "files": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "enum": [ + "applyPatch" + ], + "title": "ApplyPatchGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "cwd", + "files", + "type" + ], + "title": "ApplyPatchGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "host": { + "type": "string" + }, + "port": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + }, + "target": { + "type": "string" + }, + "type": { + "enum": [ + "networkAccess" + ], + "title": "NetworkAccessGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "host", + "port", + "protocol", + "target", + "type" + ], + "title": "NetworkAccessGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "connectorId": { + "type": [ + "string", + "null" + ] + }, + "connectorName": { + "type": [ + "string", + "null" + ] + }, + "server": { + "type": "string" + }, + "toolName": { + "type": "string" + }, + "toolTitle": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "mcpToolCall" + ], + "title": "McpToolCallGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "server", + "toolName", + "type" + ], + "title": "McpToolCallGuardianApprovalReviewAction", + "type": "object" + } + ] + }, "GuardianApprovalReviewStatus": { "description": "[UNSTABLE] Lifecycle state for a guardian approval review.", "enum": [ @@ -4857,6 +5051,13 @@ ], "type": "string" }, + "GuardianCommandSource": { + "enum": [ + "shell", + "unifiedExec" + ], + "type": "string" + }, "GuardianRiskLevel": { "description": "[UNSTABLE] Risk level assigned by guardian approval review.", "enum": [ @@ -4892,6 +5093,7 @@ "HookEventName": { "enum": [ "preToolUse", + "postToolUse", "sessionStart", "userPromptSubmit", "stop" @@ -5162,7 +5364,9 @@ "$schema": "http://json-schema.org/draft-07/schema#", "description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.\n\nTODO(ccunningham): Attach guardian review state to the reviewed tool item's lifecycle instead of sending separate standalone review notifications so the app-server API can persist and replay review state via `thread/read`.", "properties": { - "action": true, + "action": { + "$ref": "#/definitions/GuardianApprovalReviewAction" + }, "review": { "$ref": "#/definitions/GuardianApprovalReview" }, @@ -5177,6 +5381,7 @@ } }, "required": [ + "action", "review", "targetItemId", "threadId", @@ -5189,7 +5394,9 @@ "$schema": "http://json-schema.org/draft-07/schema#", "description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.\n\nTODO(ccunningham): Attach guardian review state to the reviewed tool item's lifecycle instead of sending separate standalone review notifications so the app-server API can persist and replay review state via `thread/read`.", "properties": { - "action": true, + "action": { + "$ref": "#/definitions/GuardianApprovalReviewAction" + }, "review": { "$ref": "#/definitions/GuardianApprovalReview" }, @@ -5204,6 +5411,7 @@ } }, "required": [ + "action", "review", "targetItemId", "threadId", @@ -5243,6 +5451,17 @@ "null" ] }, + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/McpServerStatusDetail" + }, + { + "type": "null" + } + ], + "description": "Controls how much MCP inventory data to fetch for each server. Defaults to `Full` when omitted." + }, "limit": { "description": "Optional page size; defaults to a server-defined value.", "format": "uint32", @@ -5382,6 +5601,22 @@ "title": "Chatgptv2::LoginAccountParams", "type": "object" }, + { + "properties": { + "type": { + "enum": [ + "chatgptDeviceCode" + ], + "title": "ChatgptDeviceCodev2::LoginAccountParamsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ChatgptDeviceCodev2::LoginAccountParams", + "type": "object" + }, { "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have.", "properties": { @@ -5463,6 +5698,36 @@ "title": "Chatgptv2::LoginAccountResponse", "type": "object" }, + { + "properties": { + "loginId": { + "type": "string" + }, + "type": { + "enum": [ + "chatgptDeviceCode" + ], + "title": "ChatgptDeviceCodev2::LoginAccountResponseType", + "type": "string" + }, + "userCode": { + "description": "One-time code the user must enter after signing in.", + "type": "string" + }, + "verificationUrl": { + "description": "URL the client should open in a browser to complete device code authorization.", + "type": "string" + } + }, + "required": [ + "loginId", + "type", + "userCode", + "verificationUrl" + ], + "title": "ChatgptDeviceCodev2::LoginAccountResponse", + "type": "object" + }, { "properties": { "type": { @@ -5522,6 +5787,43 @@ ], "type": "string" }, + "McpResourceReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "server": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": [ + "server", + "threadId", + "uri" + ], + "title": "McpResourceReadParams", + "type": "object" + }, + "McpResourceReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "contents": { + "items": { + "$ref": "#/definitions/ResourceContent" + }, + "type": "array" + } + }, + "required": [ + "contents" + ], + "title": "McpResourceReadResponse", + "type": "object" + }, "McpServerOauthLoginCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -5637,6 +5939,13 @@ ], "type": "object" }, + "McpServerStatusDetail": { + "enum": [ + "full", + "toolsAndAuthOnly" + ], + "type": "string" + }, "McpServerStatusUpdatedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -5698,6 +6007,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -6016,6 +6326,22 @@ ], "type": "string" }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5Tcp", + "socks5Udp" + ], + "type": "string" + }, + "NetworkDomainPermission": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, "NetworkRequirements": { "properties": { "allowLocalBinding": { @@ -6025,6 +6351,7 @@ ] }, "allowUnixSockets": { + "description": "Legacy compatibility view derived from `unix_sockets`.", "items": { "type": "string" }, @@ -6040,6 +6367,7 @@ ] }, "allowedDomains": { + "description": "Legacy compatibility view derived from `domains`.", "items": { "type": "string" }, @@ -6048,6 +6376,12 @@ "null" ] }, + "dangerFullAccessDenylistOnly": { + "type": [ + "boolean", + "null" + ] + }, "dangerouslyAllowAllUnixSockets": { "type": [ "boolean", @@ -6061,6 +6395,7 @@ ] }, "deniedDomains": { + "description": "Legacy compatibility view derived from `domains`.", "items": { "type": "string" }, @@ -6069,6 +6404,16 @@ "null" ] }, + "domains": { + "additionalProperties": { + "$ref": "#/definitions/NetworkDomainPermission" + }, + "description": "Canonical network permission map for `experimental_network`.", + "type": [ + "object", + "null" + ] + }, "enabled": { "type": [ "boolean", @@ -6083,6 +6428,13 @@ "null" ] }, + "managedAllowedDomainsOnly": { + "description": "When true, only managed allowlist entries are respected while managed network enforcement is active.", + "type": [ + "boolean", + "null" + ] + }, "socksPort": { "format": "uint16", "minimum": 0.0, @@ -6090,10 +6442,27 @@ "integer", "null" ] + }, + "unixSockets": { + "additionalProperties": { + "$ref": "#/definitions/NetworkUnixSocketPermission" + }, + "description": "Canonical unix socket permission map for `experimental_network`.", + "type": [ + "object", + "null" + ] } }, "type": "object" }, + "NetworkUnixSocketPermission": { + "enum": [ + "allow", + "none" + ], + "type": "string" + }, "NonSteerableTurnKind": { "enum": [ "review", @@ -6226,7 +6595,9 @@ "plus", "pro", "team", + "self_serve_business_usage_based", "business", + "enterprise_cbp_usage_based", "enterprise", "edu", "unknown" @@ -7181,6 +7552,57 @@ ], "type": "object" }, + "ResourceContent": { + "anyOf": [ + { + "properties": { + "_meta": true, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "text": { + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "type": "string" + } + }, + "required": [ + "text", + "uri" + ], + "type": "object" + }, + { + "properties": { + "_meta": true, + "blob": { + "type": "string" + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "description": "The URI of this resource.", + "type": "string" + } + }, + "required": [ + "blob", + "uri" + ], + "type": "object" + } + ], + "description": "Contents returned when reading a resource from an MCP server." + }, "ResourceTemplate": { "description": "A template description for resources available on the server.", "properties": { @@ -9756,6 +10178,13 @@ "description": "Whether the thread is ephemeral and should not be materialized on disk.", "type": "boolean" }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, "gitInfo": { "anyOf": [ { @@ -11892,6 +12321,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -11913,6 +12358,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json index 91879645de..d1812f069f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json @@ -29,7 +29,9 @@ "plus", "pro", "team", + "self_serve_business_usage_based", "business", + "enterprise_cbp_usage_based", "enterprise", "edu", "unknown" diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json index 8348b774ca..f2cf7cb3ab 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json @@ -34,7 +34,9 @@ "plus", "pro", "team", + "self_serve_business_usage_based", "business", + "enterprise_cbp_usage_based", "enterprise", "edu", "unknown" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index e0c8304c1f..ae6eb1dc7d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -1,6 +1,14 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "enum": [ + "user", + "guardian_subagent" + ], + "type": "string" + }, "AskForApproval": { "oneOf": [ { @@ -102,6 +110,13 @@ }, "type": "object" }, + "NetworkDomainPermission": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, "NetworkRequirements": { "properties": { "allowLocalBinding": { @@ -111,6 +126,7 @@ ] }, "allowUnixSockets": { + "description": "Legacy compatibility view derived from `unix_sockets`.", "items": { "type": "string" }, @@ -126,6 +142,7 @@ ] }, "allowedDomains": { + "description": "Legacy compatibility view derived from `domains`.", "items": { "type": "string" }, @@ -134,6 +151,12 @@ "null" ] }, + "dangerFullAccessDenylistOnly": { + "type": [ + "boolean", + "null" + ] + }, "dangerouslyAllowAllUnixSockets": { "type": [ "boolean", @@ -147,6 +170,7 @@ ] }, "deniedDomains": { + "description": "Legacy compatibility view derived from `domains`.", "items": { "type": "string" }, @@ -155,6 +179,16 @@ "null" ] }, + "domains": { + "additionalProperties": { + "$ref": "#/definitions/NetworkDomainPermission" + }, + "description": "Canonical network permission map for `experimental_network`.", + "type": [ + "object", + "null" + ] + }, "enabled": { "type": [ "boolean", @@ -169,6 +203,13 @@ "null" ] }, + "managedAllowedDomainsOnly": { + "description": "When true, only managed allowlist entries are respected while managed network enforcement is active.", + "type": [ + "boolean", + "null" + ] + }, "socksPort": { "format": "uint16", "minimum": 0.0, @@ -176,10 +217,27 @@ "integer", "null" ] + }, + "unixSockets": { + "additionalProperties": { + "$ref": "#/definitions/NetworkUnixSocketPermission" + }, + "description": "Canonical unix socket permission map for `experimental_network`.", + "type": [ + "object", + "null" + ] } }, "type": "object" }, + "NetworkUnixSocketPermission": { + "enum": [ + "allow", + "none" + ], + "type": "string" + }, "ResidencyRequirement": { "enum": [ "us" diff --git a/codex-rs/app-server-protocol/schema/json/v2/FsChangedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/FsChangedNotification.json index ab26588ecb..cfb9f4e5ba 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/FsChangedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/FsChangedNotification.json @@ -16,7 +16,7 @@ "type": "array" }, "watchId": { - "description": "Watch identifier returned by `fs/watch`.", + "description": "Watch identifier previously provided to `fs/watch`.", "type": "string" } }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/FsUnwatchParams.json b/codex-rs/app-server-protocol/schema/json/v2/FsUnwatchParams.json index 4b988d97aa..f46800e997 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/FsUnwatchParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/FsUnwatchParams.json @@ -3,7 +3,7 @@ "description": "Stop filesystem watch notifications for a prior `fs/watch`.", "properties": { "watchId": { - "description": "Watch identifier returned by `fs/watch`.", + "description": "Watch identifier previously provided to `fs/watch`.", "type": "string" } }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/FsWatchParams.json b/codex-rs/app-server-protocol/schema/json/v2/FsWatchParams.json index cf80c7b101..29a1ceea14 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/FsWatchParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/FsWatchParams.json @@ -15,10 +15,15 @@ } ], "description": "Absolute file or directory path to watch." + }, + "watchId": { + "description": "Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.", + "type": "string" } }, "required": [ - "path" + "path", + "watchId" ], "title": "FsWatchParams", "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/FsWatchResponse.json b/codex-rs/app-server-protocol/schema/json/v2/FsWatchResponse.json index b516636a09..abc7d466bb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/FsWatchResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/FsWatchResponse.json @@ -6,7 +6,7 @@ "type": "string" } }, - "description": "Created watch handle returned by `fs/watch`.", + "description": "Successful response for `fs/watch`.", "properties": { "path": { "allOf": [ @@ -15,15 +15,10 @@ } ], "description": "Canonicalized path associated with the watch." - }, - "watchId": { - "description": "Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.", - "type": "string" } }, "required": [ - "path", - "watchId" + "path" ], "title": "FsWatchResponse", "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json index 244156269d..23f7d3cfdc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json @@ -29,7 +29,9 @@ "plus", "pro", "team", + "self_serve_business_usage_based", "business", + "enterprise_cbp_usage_based", "enterprise", "edu", "unknown" diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json index 6646bd8c97..acaa779184 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json @@ -52,7 +52,9 @@ "plus", "pro", "team", + "self_serve_business_usage_based", "business", + "enterprise_cbp_usage_based", "enterprise", "edu", "unknown" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json index 881c343601..bce797086c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -4,6 +4,7 @@ "HookEventName": { "enum": [ "preToolUse", + "postToolUse", "sessionStart", "userPromptSubmit", "stop" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json index 18fdb5008d..72f32d0d9d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -4,6 +4,7 @@ "HookEventName": { "enum": [ "preToolUse", + "postToolUse", "sessionStart", "userPromptSubmit", "stop" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 3964107865..2883670c88 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -294,6 +294,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json index df96e86d16..66e7e5857c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json @@ -37,6 +37,176 @@ ], "type": "object" }, + "GuardianApprovalReviewAction": { + "oneOf": [ + { + "properties": { + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/GuardianCommandSource" + }, + "type": { + "enum": [ + "command" + ], + "title": "CommandGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "command", + "cwd", + "source", + "type" + ], + "title": "CommandGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "argv": { + "items": { + "type": "string" + }, + "type": "array" + }, + "cwd": { + "type": "string" + }, + "program": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/GuardianCommandSource" + }, + "type": { + "enum": [ + "execve" + ], + "title": "ExecveGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "argv", + "cwd", + "program", + "source", + "type" + ], + "title": "ExecveGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "cwd": { + "type": "string" + }, + "files": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "enum": [ + "applyPatch" + ], + "title": "ApplyPatchGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "cwd", + "files", + "type" + ], + "title": "ApplyPatchGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "host": { + "type": "string" + }, + "port": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + }, + "target": { + "type": "string" + }, + "type": { + "enum": [ + "networkAccess" + ], + "title": "NetworkAccessGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "host", + "port", + "protocol", + "target", + "type" + ], + "title": "NetworkAccessGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "connectorId": { + "type": [ + "string", + "null" + ] + }, + "connectorName": { + "type": [ + "string", + "null" + ] + }, + "server": { + "type": "string" + }, + "toolName": { + "type": "string" + }, + "toolTitle": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "mcpToolCall" + ], + "title": "McpToolCallGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "server", + "toolName", + "type" + ], + "title": "McpToolCallGuardianApprovalReviewAction", + "type": "object" + } + ] + }, "GuardianApprovalReviewStatus": { "description": "[UNSTABLE] Lifecycle state for a guardian approval review.", "enum": [ @@ -47,6 +217,13 @@ ], "type": "string" }, + "GuardianCommandSource": { + "enum": [ + "shell", + "unifiedExec" + ], + "type": "string" + }, "GuardianRiskLevel": { "description": "[UNSTABLE] Risk level assigned by guardian approval review.", "enum": [ @@ -55,11 +232,22 @@ "high" ], "type": "string" + }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5Tcp", + "socks5Udp" + ], + "type": "string" } }, "description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.\n\nTODO(ccunningham): Attach guardian review state to the reviewed tool item's lifecycle instead of sending separate standalone review notifications so the app-server API can persist and replay review state via `thread/read`.", "properties": { - "action": true, + "action": { + "$ref": "#/definitions/GuardianApprovalReviewAction" + }, "review": { "$ref": "#/definitions/GuardianApprovalReview" }, @@ -74,6 +262,7 @@ } }, "required": [ + "action", "review", "targetItemId", "threadId", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json index 339396a50b..6edfdce337 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json @@ -37,6 +37,176 @@ ], "type": "object" }, + "GuardianApprovalReviewAction": { + "oneOf": [ + { + "properties": { + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/GuardianCommandSource" + }, + "type": { + "enum": [ + "command" + ], + "title": "CommandGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "command", + "cwd", + "source", + "type" + ], + "title": "CommandGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "argv": { + "items": { + "type": "string" + }, + "type": "array" + }, + "cwd": { + "type": "string" + }, + "program": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/GuardianCommandSource" + }, + "type": { + "enum": [ + "execve" + ], + "title": "ExecveGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "argv", + "cwd", + "program", + "source", + "type" + ], + "title": "ExecveGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "cwd": { + "type": "string" + }, + "files": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "enum": [ + "applyPatch" + ], + "title": "ApplyPatchGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "cwd", + "files", + "type" + ], + "title": "ApplyPatchGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "host": { + "type": "string" + }, + "port": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + }, + "target": { + "type": "string" + }, + "type": { + "enum": [ + "networkAccess" + ], + "title": "NetworkAccessGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "host", + "port", + "protocol", + "target", + "type" + ], + "title": "NetworkAccessGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "connectorId": { + "type": [ + "string", + "null" + ] + }, + "connectorName": { + "type": [ + "string", + "null" + ] + }, + "server": { + "type": "string" + }, + "toolName": { + "type": "string" + }, + "toolTitle": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "mcpToolCall" + ], + "title": "McpToolCallGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": [ + "server", + "toolName", + "type" + ], + "title": "McpToolCallGuardianApprovalReviewAction", + "type": "object" + } + ] + }, "GuardianApprovalReviewStatus": { "description": "[UNSTABLE] Lifecycle state for a guardian approval review.", "enum": [ @@ -47,6 +217,13 @@ ], "type": "string" }, + "GuardianCommandSource": { + "enum": [ + "shell", + "unifiedExec" + ], + "type": "string" + }, "GuardianRiskLevel": { "description": "[UNSTABLE] Risk level assigned by guardian approval review.", "enum": [ @@ -55,11 +232,22 @@ "high" ], "type": "string" + }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5Tcp", + "socks5Udp" + ], + "type": "string" } }, "description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.\n\nTODO(ccunningham): Attach guardian review state to the reviewed tool item's lifecycle instead of sending separate standalone review notifications so the app-server API can persist and replay review state via `thread/read`.", "properties": { - "action": true, + "action": { + "$ref": "#/definitions/GuardianApprovalReviewAction" + }, "review": { "$ref": "#/definitions/GuardianApprovalReview" }, @@ -74,6 +262,7 @@ } }, "required": [ + "action", "review", "targetItemId", "threadId", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index abb8aee5dc..c2e71ccba9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -294,6 +294,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json b/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json index e78dbeac16..52149d9fb8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json @@ -1,5 +1,14 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "McpServerStatusDetail": { + "enum": [ + "full", + "toolsAndAuthOnly" + ], + "type": "string" + } + }, "properties": { "cursor": { "description": "Opaque pagination cursor returned by a previous call.", @@ -8,6 +17,17 @@ "null" ] }, + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/McpServerStatusDetail" + }, + { + "type": "null" + } + ], + "description": "Controls how much MCP inventory data to fetch for each server. Defaults to `Full` when omitted." + }, "limit": { "description": "Optional page size; defaults to a server-defined value.", "format": "uint32", diff --git a/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json b/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json index ce6bdd4a3f..a933b71a83 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json @@ -37,6 +37,22 @@ "title": "Chatgptv2::LoginAccountParams", "type": "object" }, + { + "properties": { + "type": { + "enum": [ + "chatgptDeviceCode" + ], + "title": "ChatgptDeviceCodev2::LoginAccountParamsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ChatgptDeviceCodev2::LoginAccountParams", + "type": "object" + }, { "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have.", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/LoginAccountResponse.json b/codex-rs/app-server-protocol/schema/json/v2/LoginAccountResponse.json index e2697ea44e..a800bffccd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/LoginAccountResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/LoginAccountResponse.json @@ -42,6 +42,36 @@ "title": "Chatgptv2::LoginAccountResponse", "type": "object" }, + { + "properties": { + "loginId": { + "type": "string" + }, + "type": { + "enum": [ + "chatgptDeviceCode" + ], + "title": "ChatgptDeviceCodev2::LoginAccountResponseType", + "type": "string" + }, + "userCode": { + "description": "One-time code the user must enter after signing in.", + "type": "string" + }, + "verificationUrl": { + "description": "URL the client should open in a browser to complete device code authorization.", + "type": "string" + } + }, + "required": [ + "loginId", + "type", + "userCode", + "verificationUrl" + ], + "title": "ChatgptDeviceCodev2::LoginAccountResponse", + "type": "object" + }, { "properties": { "type": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/McpResourceReadParams.json b/codex-rs/app-server-protocol/schema/json/v2/McpResourceReadParams.json new file mode 100644 index 0000000000..0242d4148b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/McpResourceReadParams.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "server": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": [ + "server", + "threadId", + "uri" + ], + "title": "McpResourceReadParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/McpResourceReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/McpResourceReadResponse.json new file mode 100644 index 0000000000..b1a4012344 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/McpResourceReadResponse.json @@ -0,0 +1,69 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ResourceContent": { + "anyOf": [ + { + "properties": { + "_meta": true, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "text": { + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "type": "string" + } + }, + "required": [ + "text", + "uri" + ], + "type": "object" + }, + { + "properties": { + "_meta": true, + "blob": { + "type": "string" + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "description": "The URI of this resource.", + "type": "string" + } + }, + "required": [ + "blob", + "uri" + ], + "type": "object" + } + ], + "description": "Contents returned when reading a resource from an MCP server." + } + }, + "properties": { + "contents": { + "items": { + "$ref": "#/definitions/ResourceContent" + }, + "type": "array" + } + }, + "required": [ + "contents" + ], + "title": "McpResourceReadResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 2e0c3605e7..a7fe2e8d60 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -430,6 +430,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -1267,6 +1268,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1305,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } 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 5ae0c5a122..9b1870198a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -518,6 +518,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -1042,6 +1043,13 @@ "description": "Whether the thread is ephemeral and should not be materialized on disk.", "type": "boolean" }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, "gitInfo": { "anyOf": [ { @@ -1849,6 +1857,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1870,6 +1894,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 126d78603a..426f34ce35 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -456,6 +456,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -800,6 +801,13 @@ "description": "Whether the thread is ephemeral and should not be materialized on disk.", "type": "boolean" }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, "gitInfo": { "anyOf": [ { @@ -1607,6 +1615,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1628,6 +1652,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index dfdab228df..c869a79749 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -456,6 +456,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -800,6 +801,13 @@ "description": "Whether the thread is ephemeral and should not be materialized on disk.", "type": "boolean" }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, "gitInfo": { "anyOf": [ { @@ -1607,6 +1615,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1628,6 +1652,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 8f48dee4b7..9569860c38 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -456,6 +456,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -800,6 +801,13 @@ "description": "Whether the thread is ephemeral and should not be materialized on disk.", "type": "boolean" }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, "gitInfo": { "anyOf": [ { @@ -1607,6 +1615,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1628,6 +1652,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } 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 edccc337da..5143545ec1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -518,6 +518,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -1042,6 +1043,13 @@ "description": "Whether the thread is ephemeral and should not be materialized on disk.", "type": "boolean" }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, "gitInfo": { "anyOf": [ { @@ -1849,6 +1857,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1870,6 +1894,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index cc41aac27f..502dd3961f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -456,6 +456,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -800,6 +801,13 @@ "description": "Whether the thread is ephemeral and should not be materialized on disk.", "type": "boolean" }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, "gitInfo": { "anyOf": [ { @@ -1607,6 +1615,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1628,6 +1652,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } 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 c3b50fee3b..c07b4d8258 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -518,6 +518,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -1042,6 +1043,13 @@ "description": "Whether the thread is ephemeral and should not be materialized on disk.", "type": "boolean" }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, "gitInfo": { "anyOf": [ { @@ -1849,6 +1857,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1870,6 +1894,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 2240150394..ff87af2069 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -456,6 +456,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -800,6 +801,13 @@ "description": "Whether the thread is ephemeral and should not be materialized on disk.", "type": "boolean" }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, "gitInfo": { "anyOf": [ { @@ -1607,6 +1615,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1628,6 +1652,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 41b0d2d409..daf821c374 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -456,6 +456,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -800,6 +801,13 @@ "description": "Whether the thread is ephemeral and should not be materialized on disk.", "type": "boolean" }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, "gitInfo": { "anyOf": [ { @@ -1607,6 +1615,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1628,6 +1652,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 770cc920cf..82c2b3c76c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -430,6 +430,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -1267,6 +1268,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1305,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 7f1c3e4948..ebb2065cb8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -430,6 +430,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -1267,6 +1268,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1305,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 761ddc9a62..8b7c2bc410 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -430,6 +430,7 @@ }, "McpToolCallResult": { "properties": { + "_meta": true, "content": { "items": true, "type": "array" @@ -1267,6 +1268,22 @@ }, "Turn": { "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "error": { "anyOf": [ { @@ -1288,6 +1305,14 @@ }, "type": "array" }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, "status": { "$ref": "#/definitions/TurnStatus" } diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index e33a986359..d12599d7a9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -33,6 +33,7 @@ import type { FsWriteFileParams } from "./v2/FsWriteFileParams"; import type { GetAccountParams } from "./v2/GetAccountParams"; import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams"; import type { LoginAccountParams } from "./v2/LoginAccountParams"; +import type { McpResourceReadParams } from "./v2/McpResourceReadParams"; import type { McpServerOauthLoginParams } from "./v2/McpServerOauthLoginParams"; import type { ModelListParams } from "./v2/ModelListParams"; import type { PluginInstallParams } from "./v2/PluginInstallParams"; @@ -64,4 +65,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/PlanType.ts b/codex-rs/app-server-protocol/schema/typescript/PlanType.ts index 9f622d0f1b..ad5bcab380 100644 --- a/codex-rs/app-server-protocol/schema/typescript/PlanType.ts +++ b/codex-rs/app-server-protocol/schema/typescript/PlanType.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type PlanType = "free" | "go" | "plus" | "pro" | "team" | "business" | "enterprise" | "edu" | "unknown"; +export type PlanType = "free" | "go" | "plus" | "pro" | "team" | "self_serve_business_usage_based" | "business" | "enterprise_cbp_usage_based" | "enterprise" | "edu" | "unknown"; diff --git a/codex-rs/app-server-protocol/schema/typescript/ResourceContent.ts b/codex-rs/app-server-protocol/schema/typescript/ResourceContent.ts new file mode 100644 index 0000000000..60fe239dc4 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/ResourceContent.ts @@ -0,0 +1,17 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * Contents returned when reading a resource from an MCP server. + */ +export type ResourceContent = { +/** + * The URI of this resource. + */ +uri: string, mimeType?: string, text: string, _meta?: JsonValue, } | { +/** + * The URI of this resource. + */ +uri: string, mimeType?: string, blob: string, _meta?: JsonValue, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 777feaa56e..7ffc15e83d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -41,9 +41,6 @@ export type { InputModality } from "./InputModality"; export type { LocalShellAction } from "./LocalShellAction"; export type { LocalShellExecAction } from "./LocalShellExecAction"; export type { LocalShellStatus } from "./LocalShellStatus"; -export type { MacOsAutomationPermission } from "./MacOsAutomationPermission"; -export type { MacOsContactsPermission } from "./MacOsContactsPermission"; -export type { MacOsPreferencesPermission } from "./MacOsPreferencesPermission"; export type { MessagePhase } from "./MessagePhase"; export type { ModeKind } from "./ModeKind"; export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; @@ -58,6 +55,7 @@ export type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSumm export type { ReasoningSummary } from "./ReasoningSummary"; export type { RequestId } from "./RequestId"; export type { Resource } from "./Resource"; +export type { ResourceContent } from "./ResourceContent"; export type { ResourceTemplate } from "./ResourceTemplate"; export type { ResponseItem } from "./ResponseItem"; export type { ReviewDecision } from "./ReviewDecision"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalMacOsPermissions.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalMacOsPermissions.ts deleted file mode 100644 index 177661bb0e..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalMacOsPermissions.ts +++ /dev/null @@ -1,8 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { MacOsAutomationPermission } from "../MacOsAutomationPermission"; -import type { MacOsContactsPermission } from "../MacOsContactsPermission"; -import type { MacOsPreferencesPermission } from "../MacOsPreferencesPermission"; - -export type AdditionalMacOsPermissions = { preferences: MacOsPreferencesPermission, automations: MacOsAutomationPermission, launchServices: boolean, accessibility: boolean, calendar: boolean, reminders: boolean, contacts: MacOsContactsPermission, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalPermissionProfile.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalPermissionProfile.ts index 701ba54316..65836c119d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalPermissionProfile.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalPermissionProfile.ts @@ -2,7 +2,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; -import type { AdditionalMacOsPermissions } from "./AdditionalMacOsPermissions"; import type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions"; -export type AdditionalPermissionProfile = { network: AdditionalNetworkPermissions | null, fileSystem: AdditionalFileSystemPermissions | null, macos: AdditionalMacOsPermissions | null, }; +export type AdditionalPermissionProfile = { network: AdditionalNetworkPermissions | null, fileSystem: AdditionalFileSystemPermissions | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts index 623fb971c1..8fb6375e69 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts @@ -4,7 +4,6 @@ import type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile"; import type { CommandAction } from "./CommandAction"; import type { CommandExecutionApprovalDecision } from "./CommandExecutionApprovalDecision"; -import type { CommandExecutionRequestApprovalSkillMetadata } from "./CommandExecutionRequestApprovalSkillMetadata"; import type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; import type { NetworkApprovalContext } from "./NetworkApprovalContext"; import type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; @@ -44,10 +43,6 @@ commandActions?: Array | null, * Optional additional permissions requested for this command. */ additionalPermissions?: AdditionalPermissionProfile | null, -/** - * Optional skill metadata when the approval was triggered by a skill script. - */ -skillMetadata?: CommandExecutionRequestApprovalSkillMetadata | null, /** * Optional proposed execpolicy amendment to allow similar commands without prompting. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FsChangedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FsChangedNotification.ts index 2e9bd0d6ec..fc0fa9cf6d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/FsChangedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FsChangedNotification.ts @@ -8,7 +8,7 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; */ export type FsChangedNotification = { /** - * Watch identifier returned by `fs/watch`. + * Watch identifier previously provided to `fs/watch`. */ watchId: string, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FsUnwatchParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FsUnwatchParams.ts index b21befdb5c..34b70a9307 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/FsUnwatchParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FsUnwatchParams.ts @@ -7,6 +7,6 @@ */ export type FsUnwatchParams = { /** - * Watch identifier returned by `fs/watch`. + * Watch identifier previously provided to `fs/watch`. */ watchId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FsWatchParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FsWatchParams.ts index d6d956b288..60755538cb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/FsWatchParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FsWatchParams.ts @@ -7,6 +7,10 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; * Start filesystem watch notifications for an absolute path. */ export type FsWatchParams = { +/** + * Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`. + */ +watchId: string, /** * Absolute file or directory path to watch. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FsWatchResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FsWatchResponse.ts index 1927272821..b640306d82 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/FsWatchResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FsWatchResponse.ts @@ -4,13 +4,9 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; /** - * Created watch handle returned by `fs/watch`. + * Successful response for `fs/watch`. */ export type FsWatchResponse = { -/** - * Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`. - */ -watchId: string, /** * Canonicalized path associated with the watch. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GuardianApprovalReviewAction.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GuardianApprovalReviewAction.ts new file mode 100644 index 0000000000..101fe3f3ef --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GuardianApprovalReviewAction.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { GuardianCommandSource } from "./GuardianCommandSource"; +import type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; + +export type GuardianApprovalReviewAction = { "type": "command", source: GuardianCommandSource, command: string, cwd: string, } | { "type": "execve", source: GuardianCommandSource, program: string, argv: Array, cwd: string, } | { "type": "applyPatch", cwd: string, files: Array, } | { "type": "networkAccess", target: string, host: string, protocol: NetworkApprovalProtocol, port: number, } | { "type": "mcpToolCall", server: string, toolName: string, connectorId: string | null, connectorName: string | null, toolTitle: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/MacOsContactsPermission.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GuardianCommandSource.ts similarity index 67% rename from codex-rs/app-server-protocol/schema/typescript/MacOsContactsPermission.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/GuardianCommandSource.ts index dd6d7b59ef..b48e9b0826 100644 --- a/codex-rs/app-server-protocol/schema/typescript/MacOsContactsPermission.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GuardianCommandSource.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type MacOsContactsPermission = "none" | "read_only" | "read_write"; +export type GuardianCommandSource = "shell" | "unifiedExec"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts index b75ee3930a..b97c709b98 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HookEventName = "preToolUse" | "sessionStart" | "userPromptSubmit" | "stop"; +export type HookEventName = "preToolUse" | "postToolUse" | "sessionStart" | "userPromptSubmit" | "stop"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewCompletedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewCompletedNotification.ts index ac4ae1b78a..a7ae038b36 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewCompletedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewCompletedNotification.ts @@ -1,8 +1,8 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { JsonValue } from "../serde_json/JsonValue"; import type { GuardianApprovalReview } from "./GuardianApprovalReview"; +import type { GuardianApprovalReviewAction } from "./GuardianApprovalReviewAction"; /** * [UNSTABLE] Temporary notification payload for guardian automatic approval @@ -12,4 +12,4 @@ import type { GuardianApprovalReview } from "./GuardianApprovalReview"; * lifecycle instead of sending separate standalone review notifications so the * app-server API can persist and replay review state via `thread/read`. */ -export type ItemGuardianApprovalReviewCompletedNotification = { threadId: string, turnId: string, targetItemId: string, review: GuardianApprovalReview, action: JsonValue | null, }; +export type ItemGuardianApprovalReviewCompletedNotification = { threadId: string, turnId: string, targetItemId: string, review: GuardianApprovalReview, action: GuardianApprovalReviewAction, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewStartedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewStartedNotification.ts index b229626817..706b8d55af 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewStartedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewStartedNotification.ts @@ -1,8 +1,8 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { JsonValue } from "../serde_json/JsonValue"; import type { GuardianApprovalReview } from "./GuardianApprovalReview"; +import type { GuardianApprovalReviewAction } from "./GuardianApprovalReviewAction"; /** * [UNSTABLE] Temporary notification payload for guardian automatic approval @@ -12,4 +12,4 @@ import type { GuardianApprovalReview } from "./GuardianApprovalReview"; * lifecycle instead of sending separate standalone review notifications so the * app-server API can persist and replay review state via `thread/read`. */ -export type ItemGuardianApprovalReviewStartedNotification = { threadId: string, turnId: string, targetItemId: string, review: GuardianApprovalReview, action: JsonValue | null, }; +export type ItemGuardianApprovalReviewStartedNotification = { threadId: string, turnId: string, targetItemId: string, review: GuardianApprovalReview, action: GuardianApprovalReviewAction, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ListMcpServerStatusParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ListMcpServerStatusParams.ts index 05c02c19f8..8225c462b1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ListMcpServerStatusParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ListMcpServerStatusParams.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpServerStatusDetail } from "./McpServerStatusDetail"; export type ListMcpServerStatusParams = { /** @@ -10,4 +11,9 @@ cursor?: string | null, /** * Optional page size; defaults to a server-defined value. */ -limit?: number | null, }; +limit?: number | null, +/** + * Controls how much MCP inventory data to fetch for each server. + * Defaults to `Full` when omitted. + */ +detail?: McpServerStatusDetail | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts index ef668f9c1a..9fcb01f97c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts @@ -2,7 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt" } | { "type": "chatgptAuthTokens", +export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt" } | { "type": "chatgptDeviceCode" } | { "type": "chatgptAuthTokens", /** * Access token (JWT) supplied by the client. * This token is used for backend API requests and email extraction. diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountResponse.ts index cd79f6c83f..651f171e31 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountResponse.ts @@ -6,4 +6,12 @@ export type LoginAccountResponse = { "type": "apiKey", } | { "type": "chatgpt", /** * URL the client should open in a browser to initiate the OAuth flow. */ -authUrl: string, } | { "type": "chatgptAuthTokens", }; +authUrl: string, } | { "type": "chatgptDeviceCode", loginId: string, +/** + * URL the client should open in a browser to complete device code authorization. + */ +verificationUrl: string, +/** + * One-time code the user must enter after signing in. + */ +userCode: string, } | { "type": "chatgptAuthTokens", }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalSkillMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpResourceReadParams.ts similarity index 63% rename from codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalSkillMetadata.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/McpResourceReadParams.ts index dcfd7c2916..51d650d9bb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalSkillMetadata.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpResourceReadParams.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CommandExecutionRequestApprovalSkillMetadata = { pathToSkillsMd: string, }; +export type McpResourceReadParams = { threadId: string, server: string, uri: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpResourceReadResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpResourceReadResponse.ts new file mode 100644 index 0000000000..2af1dbcd09 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpResourceReadResponse.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 { ResourceContent } from "../ResourceContent"; + +export type McpResourceReadResponse = { contents: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/MacOsPreferencesPermission.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatusDetail.ts similarity index 66% rename from codex-rs/app-server-protocol/schema/typescript/MacOsPreferencesPermission.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatusDetail.ts index 2f5234a268..ab97cc2f31 100644 --- a/codex-rs/app-server-protocol/schema/typescript/MacOsPreferencesPermission.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatusDetail.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type MacOsPreferencesPermission = "none" | "read_only" | "read_write"; +export type McpServerStatusDetail = "full" | "toolsAndAuthOnly"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpToolCallResult.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpToolCallResult.ts index f493a86094..916a5f5bb3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/McpToolCallResult.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpToolCallResult.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { JsonValue } from "../serde_json/JsonValue"; -export type McpToolCallResult = { content: Array, structuredContent: JsonValue | null, }; +export type McpToolCallResult = { content: Array, structuredContent: JsonValue | null, _meta: JsonValue | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/MacOsAutomationPermission.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkDomainPermission.ts similarity index 62% rename from codex-rs/app-server-protocol/schema/typescript/MacOsAutomationPermission.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/NetworkDomainPermission.ts index 31036b23ee..2ea44392de 100644 --- a/codex-rs/app-server-protocol/schema/typescript/MacOsAutomationPermission.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkDomainPermission.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type MacOsAutomationPermission = "none" | "all" | { "bundle_ids": Array }; +export type NetworkDomainPermission = "allow" | "deny"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts index 1f1653c277..c685b9539c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkRequirements.ts @@ -1,5 +1,32 @@ // 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 { NetworkDomainPermission } from "./NetworkDomainPermission"; +import type { NetworkUnixSocketPermission } from "./NetworkUnixSocketPermission"; -export type NetworkRequirements = { enabled: boolean | null, httpPort: number | null, socksPort: number | null, allowUpstreamProxy: boolean | null, dangerouslyAllowNonLoopbackProxy: boolean | null, dangerouslyAllowAllUnixSockets: boolean | null, allowedDomains: Array | null, deniedDomains: Array | null, allowUnixSockets: Array | null, allowLocalBinding: boolean | null, }; +export type NetworkRequirements = { enabled: boolean | null, httpPort: number | null, socksPort: number | null, allowUpstreamProxy: boolean | null, dangerouslyAllowNonLoopbackProxy: boolean | null, dangerouslyAllowAllUnixSockets: boolean | null, +/** + * Canonical network permission map for `experimental_network`. + */ +domains: { [key in string]?: NetworkDomainPermission } | null, +/** + * When true, only managed allowlist entries are respected while managed + * network enforcement is active. + */ +managedAllowedDomainsOnly: boolean | null, +/** + * Legacy compatibility view derived from `domains`. + */ +allowedDomains: Array | null, +/** + * Legacy compatibility view derived from `domains`. + */ +deniedDomains: Array | null, +/** + * Canonical unix socket permission map for `experimental_network`. + */ +unixSockets: { [key in string]?: NetworkUnixSocketPermission } | null, +/** + * Legacy compatibility view derived from `unix_sockets`. + */ +allowUnixSockets: Array | null, allowLocalBinding: boolean | null, dangerFullAccessDenylistOnly: boolean | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkUnixSocketPermission.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkUnixSocketPermission.ts new file mode 100644 index 0000000000..466c6e5f8f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkUnixSocketPermission.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 NetworkUnixSocketPermission = "allow" | "none"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts index 56c5688495..7a8bf4aca4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -7,6 +7,10 @@ import type { ThreadStatus } from "./ThreadStatus"; import type { Turn } from "./Turn"; export type Thread = { id: string, +/** + * Source thread id when this thread was created by forking another thread. + */ +forkedFromId: string | null, /** * Usually the first user message in the thread, if available. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts index a7ba311803..952da565a6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts @@ -27,7 +27,7 @@ model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier * Override where approval requests are routed for review on this thread * and subsequent turns. */ -approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean, /** +approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null | null, developerInstructions?: string | null | null, ephemeral?: boolean, /** * If true, persist additional rollout EventMsg variants required to * reconstruct a richer thread history on subsequent resume/fork/read. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts index 770344de8e..fcde0ae502 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts @@ -36,7 +36,7 @@ model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier * Override where approval requests are routed for review on this thread * and subsequent turns. */ -approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, /** +approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null | null, developerInstructions?: string | null | null, personality?: Personality | null, /** * If true, persist additional rollout EventMsg variants required to * reconstruct a richer thread history on subsequent resume/fork/read. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts index 61f501ad60..c7b27823f6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts @@ -12,7 +12,7 @@ export type ThreadStartParams = {model?: string | null, modelProvider?: string | * Override where approval requests are routed for review on this thread * and subsequent turns. */ -approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, /** +approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null | null, developerInstructions?: string | null | null, personality?: Personality | null, ephemeral?: boolean | null, /** * If true, opt into emitting raw Responses API items on the event stream. * This is for internal use only (e.g. Codex Cloud). */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 709ed5ccbe..074ac215fd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -15,4 +15,16 @@ items: Array, status: TurnStatus, /** * Only populated when the Turn's status is failed. */ -error: TurnError | null, }; +error: TurnError | null, +/** + * Unix timestamp (in seconds) when the turn started. + */ +startedAt: number | null, +/** + * Unix timestamp (in seconds) when the turn completed. + */ +completedAt: number | null, +/** + * Duration between turn start and completion in milliseconds, if known. + */ +durationMs: number | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index d0b1b88198..4e75a31e38 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -5,7 +5,6 @@ export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedN export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification"; export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; -export type { AdditionalMacOsPermissions } from "./AdditionalMacOsPermissions"; export type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions"; export type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile"; export type { AgentMessageDeltaNotification } from "./AgentMessageDeltaNotification"; @@ -54,7 +53,6 @@ export type { CommandExecutionApprovalDecision } from "./CommandExecutionApprova export type { CommandExecutionOutputDeltaNotification } from "./CommandExecutionOutputDeltaNotification"; export type { CommandExecutionRequestApprovalParams } from "./CommandExecutionRequestApprovalParams"; export type { CommandExecutionRequestApprovalResponse } from "./CommandExecutionRequestApprovalResponse"; -export type { CommandExecutionRequestApprovalSkillMetadata } from "./CommandExecutionRequestApprovalSkillMetadata"; export type { CommandExecutionSource } from "./CommandExecutionSource"; export type { CommandExecutionStatus } from "./CommandExecutionStatus"; export type { Config } from "./Config"; @@ -125,7 +123,9 @@ export type { GetAccountResponse } from "./GetAccountResponse"; export type { GitInfo } from "./GitInfo"; export type { GrantedPermissionProfile } from "./GrantedPermissionProfile"; export type { GuardianApprovalReview } from "./GuardianApprovalReview"; +export type { GuardianApprovalReviewAction } from "./GuardianApprovalReviewAction"; export type { GuardianApprovalReviewStatus } from "./GuardianApprovalReviewStatus"; +export type { GuardianCommandSource } from "./GuardianCommandSource"; export type { GuardianRiskLevel } from "./GuardianRiskLevel"; export type { HookCompletedNotification } from "./HookCompletedNotification"; export type { HookEventName } from "./HookEventName"; @@ -172,6 +172,8 @@ export type { McpElicitationTitledSingleSelectEnumSchema } from "./McpElicitatio export type { McpElicitationUntitledEnumItems } from "./McpElicitationUntitledEnumItems"; export type { McpElicitationUntitledMultiSelectEnumSchema } from "./McpElicitationUntitledMultiSelectEnumSchema"; export type { McpElicitationUntitledSingleSelectEnumSchema } from "./McpElicitationUntitledSingleSelectEnumSchema"; +export type { McpResourceReadParams } from "./McpResourceReadParams"; +export type { McpResourceReadResponse } from "./McpResourceReadResponse"; export type { McpServerElicitationAction } from "./McpServerElicitationAction"; export type { McpServerElicitationRequestParams } from "./McpServerElicitationRequestParams"; export type { McpServerElicitationRequestResponse } from "./McpServerElicitationRequestResponse"; @@ -181,6 +183,7 @@ export type { McpServerOauthLoginResponse } from "./McpServerOauthLoginResponse" export type { McpServerRefreshResponse } from "./McpServerRefreshResponse"; export type { McpServerStartupState } from "./McpServerStartupState"; export type { McpServerStatus } from "./McpServerStatus"; +export type { McpServerStatusDetail } from "./McpServerStatusDetail"; export type { McpServerStatusUpdatedNotification } from "./McpServerStatusUpdatedNotification"; export type { McpToolCallError } from "./McpToolCallError"; export type { McpToolCallProgressNotification } from "./McpToolCallProgressNotification"; @@ -199,9 +202,11 @@ export type { ModelUpgradeInfo } from "./ModelUpgradeInfo"; export type { NetworkAccess } from "./NetworkAccess"; export type { NetworkApprovalContext } from "./NetworkApprovalContext"; export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; +export type { NetworkDomainPermission } from "./NetworkDomainPermission"; export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction"; export type { NetworkRequirements } from "./NetworkRequirements"; +export type { NetworkUnixSocketPermission } from "./NetworkUnixSocketPermission"; export type { NonSteerableTurnKind } from "./NonSteerableTurnKind"; export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index b89f23c666..358bb5c2df 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -2298,10 +2298,6 @@ mod tests { command_execution_request_approval_ts.contains("additionalPermissions"), true ); - assert_eq!( - command_execution_request_approval_ts.contains("skillMetadata"), - true - ); Ok(()) } @@ -2694,7 +2690,7 @@ export type Config = { stableField: Keep, unstableField: string | null } & ({ [k fn generate_json_filters_experimental_fields_and_methods() -> Result<()> { let output_dir = std::env::temp_dir().join(format!("codex_schema_{}", Uuid::now_v7())); fs::create_dir(&output_dir)?; - generate_json_with_experimental(&output_dir, false)?; + generate_json_with_experimental(&output_dir, /*experimental_api*/ false)?; let thread_start_json = fs::read_to_string(output_dir.join("v2").join("ThreadStartParams.json"))?; @@ -2705,10 +2701,6 @@ export type Config = { stableField: Keep, unstableField: string | null } & ({ [k command_execution_request_approval_json.contains("additionalPermissions"), false ); - assert_eq!( - command_execution_request_approval_json.contains("skillMetadata"), - false - ); let client_request_json = fs::read_to_string(output_dir.join("ClientRequest.json"))?; assert_eq!( @@ -2721,7 +2713,6 @@ export type Config = { stableField: Keep, unstableField: string | null } & ({ [k fs::read_to_string(output_dir.join("codex_app_server_protocol.schemas.json"))?; assert_eq!(bundle_json.contains("mockExperimentalField"), false); assert_eq!(bundle_json.contains("additionalPermissions"), false); - assert_eq!(bundle_json.contains("skillMetadata"), false); assert_eq!(bundle_json.contains("MockExperimentalMethodParams"), false); assert_eq!( bundle_json.contains("MockExperimentalMethodResponse"), @@ -2731,7 +2722,6 @@ export type Config = { stableField: Keep, unstableField: string | null } & ({ [k fs::read_to_string(output_dir.join("codex_app_server_protocol.v2.schemas.json"))?; assert_eq!(flat_v2_bundle_json.contains("mockExperimentalField"), false); assert_eq!(flat_v2_bundle_json.contains("additionalPermissions"), false); - assert_eq!(flat_v2_bundle_json.contains("skillMetadata"), false); assert_eq!( flat_v2_bundle_json.contains("MockExperimentalMethodParams"), false diff --git a/codex-rs/app-server-protocol/src/lib.rs b/codex-rs/app-server-protocol/src/lib.rs index b1a8f474f3..d5c2f4b243 100644 --- a/codex-rs/app-server-protocol/src/lib.rs +++ b/codex-rs/app-server-protocol/src/lib.rs @@ -15,6 +15,7 @@ pub use export::generate_ts_with_options; pub use export::generate_types; pub use jsonrpc_lite::*; pub use protocol::common::*; +pub use protocol::item_builders::*; pub use protocol::thread_history::*; pub use protocol::v1::ApplyPatchApprovalParams; pub use protocol::v1::ApplyPatchApprovalResponse; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 5a9215f2ee..3ac96fa0eb 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -120,6 +120,41 @@ macro_rules! client_request_definitions { } } + /// Typed response from the server to the client. + #[derive(Serialize, Deserialize, Debug, Clone)] + #[serde(tag = "method", rename_all = "camelCase")] + pub enum ClientResponse { + $( + $(#[doc = $variant_doc])* + $(#[serde(rename = $wire)])? + $variant { + #[serde(rename = "id")] + request_id: RequestId, + response: $response, + }, + )* + } + + impl ClientResponse { + pub fn id(&self) -> &RequestId { + match self { + $(Self::$variant { request_id, .. } => request_id,)* + } + } + + pub fn method(&self) -> String { + serde_json::to_value(self) + .ok() + .and_then(|value| { + value + .get("method") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }) + .unwrap_or_else(|| "".to_string()) + } + } + impl crate::experimental_api::ExperimentalApi for ClientRequest { fn experimental_reason(&self) -> Option<&'static str> { match self { @@ -424,6 +459,11 @@ client_request_definitions! { response: v2::ListMcpServerStatusResponse, }, + McpResourceRead => "mcpServer/resource/read" { + params: v2::McpResourceReadParams, + response: v2::McpResourceReadResponse, + }, + WindowsSandboxSetupStart => "windowsSandbox/setupStart" { params: v2::WindowsSandboxSetupStartParams, response: v2::WindowsSandboxSetupStartResponse, @@ -1265,6 +1305,86 @@ mod tests { Ok(()) } + #[test] + fn serialize_client_response() -> Result<()> { + let response = ClientResponse::ThreadStart { + request_id: RequestId::Integer(7), + response: v2::ThreadStartResponse { + thread: v2::Thread { + id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), + forked_from_id: None, + preview: "first prompt".to_string(), + ephemeral: true, + model_provider: "openai".to_string(), + created_at: 1, + updated_at: 2, + status: v2::ThreadStatus::Idle, + path: None, + cwd: PathBuf::from("/tmp"), + cli_version: "0.0.0".to_string(), + source: v2::SessionSource::Exec, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: Vec::new(), + }, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + service_tier: None, + cwd: PathBuf::from("/tmp"), + approval_policy: v2::AskForApproval::OnFailure, + approvals_reviewer: v2::ApprovalsReviewer::User, + sandbox: v2::SandboxPolicy::DangerFullAccess, + reasoning_effort: None, + }, + }; + + assert_eq!(response.id(), &RequestId::Integer(7)); + assert_eq!(response.method(), "thread/start"); + assert_eq!( + json!({ + "method": "thread/start", + "id": 7, + "response": { + "thread": { + "id": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "forkedFromId": null, + "preview": "first prompt", + "ephemeral": true, + "modelProvider": "openai", + "createdAt": 1, + "updatedAt": 2, + "status": { + "type": "idle" + }, + "path": null, + "cwd": "/tmp", + "cliVersion": "0.0.0", + "source": "exec", + "agentNickname": null, + "agentRole": null, + "gitInfo": null, + "name": null, + "turns": [] + }, + "model": "gpt-5", + "modelProvider": "openai", + "serviceTier": null, + "cwd": "/tmp", + "approvalPolicy": "on-failure", + "approvalsReviewer": "user", + "sandbox": { + "type": "dangerFullAccess" + }, + "reasoningEffort": null + } + }), + serde_json::to_value(&response)?, + ); + Ok(()) + } + #[test] fn serialize_config_requirements_read() -> Result<()> { let request = ClientRequest::ConfigRequirementsRead { @@ -1322,16 +1442,35 @@ mod tests { Ok(()) } + #[test] + fn serialize_account_login_chatgpt_device_code() -> Result<()> { + let request = ClientRequest::LoginAccount { + request_id: RequestId::Integer(4), + params: v2::LoginAccountParams::ChatgptDeviceCode, + }; + assert_eq!( + json!({ + "method": "account/login/start", + "id": 4, + "params": { + "type": "chatgptDeviceCode" + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn serialize_account_logout() -> Result<()> { let request = ClientRequest::LogoutAccount { - request_id: RequestId::Integer(4), + request_id: RequestId::Integer(5), params: None, }; assert_eq!( json!({ "method": "account/logout", - "id": 4, + "id": 5, }), serde_json::to_value(&request)?, ); @@ -1341,7 +1480,7 @@ mod tests { #[test] fn serialize_account_login_chatgpt_auth_tokens() -> Result<()> { let request = ClientRequest::LoginAccount { - request_id: RequestId::Integer(5), + request_id: RequestId::Integer(6), params: v2::LoginAccountParams::ChatgptAuthTokens { access_token: "access-token".to_string(), chatgpt_account_id: "org-123".to_string(), @@ -1351,7 +1490,7 @@ mod tests { assert_eq!( json!({ "method": "account/login/start", - "id": 5, + "id": 6, "params": { "type": "chatgptAuthTokens", "accessToken": "access-token", @@ -1496,6 +1635,7 @@ mod tests { let request = ClientRequest::FsWatch { request_id: RequestId::Integer(10), params: v2::FsWatchParams { + watch_id: "watch-git".to_string(), path: absolute_path("tmp/repo/.git"), }, }; @@ -1504,6 +1644,7 @@ mod tests { "method": "fs/watch", "id": 10, "params": { + "watchId": "watch-git", "path": absolute_path_string("tmp/repo/.git") } }), @@ -1703,9 +1844,7 @@ mod tests { read: Some(vec![absolute_path("/tmp/allowed")]), write: None, }), - macos: None, }), - skill_metadata: None, proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, available_decisions: None, @@ -1716,31 +1855,4 @@ mod tests { Some("item/commandExecution/requestApproval.additionalPermissions") ); } - - #[test] - fn command_execution_request_approval_skill_metadata_is_marked_experimental() { - let params = v2::CommandExecutionRequestApprovalParams { - thread_id: "thr_123".to_string(), - turn_id: "turn_123".to_string(), - item_id: "call_123".to_string(), - approval_id: None, - reason: None, - network_approval_context: None, - command: Some("cat file".to_string()), - cwd: None, - command_actions: None, - additional_permissions: None, - skill_metadata: Some(v2::CommandExecutionRequestApprovalSkillMetadata { - path_to_skills_md: PathBuf::from("/tmp/SKILLS.md"), - }), - proposed_execpolicy_amendment: None, - proposed_network_policy_amendments: None, - available_decisions: None, - }; - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(¶ms); - assert_eq!( - reason, - Some("item/commandExecution/requestApproval.skillMetadata") - ); - } } diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders.rs b/codex-rs/app-server-protocol/src/protocol/item_builders.rs new file mode 100644 index 0000000000..804169db6f --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/item_builders.rs @@ -0,0 +1,299 @@ +//! Shared builders for synthetic [`ThreadItem`] values emitted by the app-server layer. +//! +//! These items do not come from first-class core `ItemStarted` / `ItemCompleted` events. +//! Instead, the app-server synthesizes them so clients can render a coherent lifecycle for +//! approvals and other pre-execution flows before the underlying tool has started or when the +//! tool never starts at all. +//! +//! Keeping these builders in one place is useful for two reasons: +//! - Live notifications and rebuilt `thread/read` history both need to construct the same +//! synthetic items, so sharing the logic avoids drift between those paths. +//! - The projection is presentation-specific. Core protocol events stay generic, while the +//! app-server protocol decides how to surface those events as `ThreadItem`s for clients. +use crate::protocol::common::ServerNotification; +use crate::protocol::v2::CommandAction; +use crate::protocol::v2::CommandExecutionSource; +use crate::protocol::v2::CommandExecutionStatus; +use crate::protocol::v2::FileUpdateChange; +use crate::protocol::v2::GuardianApprovalReview; +use crate::protocol::v2::GuardianApprovalReviewStatus; +use crate::protocol::v2::ItemGuardianApprovalReviewCompletedNotification; +use crate::protocol::v2::ItemGuardianApprovalReviewStartedNotification; +use crate::protocol::v2::PatchApplyStatus; +use crate::protocol::v2::PatchChangeKind; +use crate::protocol::v2::ThreadItem; +use codex_protocol::ThreadId; +use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; +use codex_protocol::protocol::ExecApprovalRequestEvent; +use codex_protocol::protocol::ExecCommandBeginEvent; +use codex_protocol::protocol::ExecCommandEndEvent; +use codex_protocol::protocol::FileChange; +use codex_protocol::protocol::GuardianAssessmentAction; +use codex_protocol::protocol::GuardianAssessmentEvent; +use codex_protocol::protocol::PatchApplyBeginEvent; +use codex_protocol::protocol::PatchApplyEndEvent; +use codex_shell_command::parse_command::parse_command; +use codex_shell_command::parse_command::shlex_join; +use std::collections::HashMap; +use std::path::PathBuf; + +pub fn build_file_change_approval_request_item( + payload: &ApplyPatchApprovalRequestEvent, +) -> ThreadItem { + ThreadItem::FileChange { + id: payload.call_id.clone(), + changes: convert_patch_changes(&payload.changes), + status: PatchApplyStatus::InProgress, + } +} + +pub fn build_file_change_begin_item(payload: &PatchApplyBeginEvent) -> ThreadItem { + ThreadItem::FileChange { + id: payload.call_id.clone(), + changes: convert_patch_changes(&payload.changes), + status: PatchApplyStatus::InProgress, + } +} + +pub fn build_file_change_end_item(payload: &PatchApplyEndEvent) -> ThreadItem { + ThreadItem::FileChange { + id: payload.call_id.clone(), + changes: convert_patch_changes(&payload.changes), + status: (&payload.status).into(), + } +} + +pub fn build_command_execution_approval_request_item( + payload: &ExecApprovalRequestEvent, +) -> ThreadItem { + ThreadItem::CommandExecution { + id: payload.call_id.clone(), + command: shlex_join(&payload.command), + cwd: payload.cwd.clone(), + process_id: None, + source: CommandExecutionSource::Agent, + status: CommandExecutionStatus::InProgress, + command_actions: payload + .parsed_cmd + .iter() + .cloned() + .map(CommandAction::from) + .collect(), + aggregated_output: None, + exit_code: None, + duration_ms: None, + } +} + +pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> ThreadItem { + ThreadItem::CommandExecution { + id: payload.call_id.clone(), + command: shlex_join(&payload.command), + cwd: payload.cwd.clone(), + process_id: payload.process_id.clone(), + source: payload.source.into(), + status: CommandExecutionStatus::InProgress, + command_actions: payload + .parsed_cmd + .iter() + .cloned() + .map(CommandAction::from) + .collect(), + aggregated_output: None, + exit_code: None, + duration_ms: None, + } +} + +pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> ThreadItem { + let aggregated_output = if payload.aggregated_output.is_empty() { + None + } else { + Some(payload.aggregated_output.clone()) + }; + let duration_ms = i64::try_from(payload.duration.as_millis()).unwrap_or(i64::MAX); + + ThreadItem::CommandExecution { + id: payload.call_id.clone(), + command: shlex_join(&payload.command), + cwd: payload.cwd.clone(), + process_id: payload.process_id.clone(), + source: payload.source.into(), + status: (&payload.status).into(), + command_actions: payload + .parsed_cmd + .iter() + .cloned() + .map(CommandAction::from) + .collect(), + aggregated_output, + exit_code: Some(payload.exit_code), + duration_ms: Some(duration_ms), + } +} + +/// Build a guardian-derived [`ThreadItem`]. +/// +/// Currently this only synthesizes [`ThreadItem::CommandExecution`] for +/// [`GuardianAssessmentAction::Command`] and [`GuardianAssessmentAction::Execve`]. +pub fn build_item_from_guardian_event( + assessment: &GuardianAssessmentEvent, + status: CommandExecutionStatus, +) -> Option { + match &assessment.action { + GuardianAssessmentAction::Command { command, cwd, .. } => { + let command = command.clone(); + let command_actions = vec![CommandAction::Unknown { + command: command.clone(), + }]; + Some(ThreadItem::CommandExecution { + id: assessment.id.clone(), + command, + cwd: cwd.clone(), + process_id: None, + source: CommandExecutionSource::Agent, + status, + command_actions, + aggregated_output: None, + exit_code: None, + duration_ms: None, + }) + } + GuardianAssessmentAction::Execve { + program, argv, cwd, .. + } => { + let argv = if argv.is_empty() { + vec![program.clone()] + } else { + std::iter::once(program.clone()) + .chain(argv.iter().skip(1).cloned()) + .collect::>() + }; + let command = shlex_join(&argv); + let parsed_cmd = parse_command(&argv); + let command_actions = if parsed_cmd.is_empty() { + vec![CommandAction::Unknown { + command: command.clone(), + }] + } else { + parsed_cmd.into_iter().map(CommandAction::from).collect() + }; + Some(ThreadItem::CommandExecution { + id: assessment.id.clone(), + command, + cwd: cwd.clone(), + process_id: None, + source: CommandExecutionSource::Agent, + status, + command_actions, + aggregated_output: None, + exit_code: None, + duration_ms: None, + }) + } + GuardianAssessmentAction::ApplyPatch { .. } + | GuardianAssessmentAction::NetworkAccess { .. } + | GuardianAssessmentAction::McpToolCall { .. } => None, + } +} + +pub fn guardian_auto_approval_review_notification( + conversation_id: &ThreadId, + event_turn_id: &str, + assessment: &GuardianAssessmentEvent, +) -> ServerNotification { + // TODO(ccunningham): Attach guardian review state to the reviewed tool + // item's lifecycle instead of sending standalone review notifications so + // the app-server API can persist and replay review state via `thread/read`. + let turn_id = if assessment.turn_id.is_empty() { + event_turn_id.to_string() + } else { + assessment.turn_id.clone() + }; + let review = GuardianApprovalReview { + status: match assessment.status { + codex_protocol::protocol::GuardianAssessmentStatus::InProgress => { + GuardianApprovalReviewStatus::InProgress + } + codex_protocol::protocol::GuardianAssessmentStatus::Approved => { + GuardianApprovalReviewStatus::Approved + } + codex_protocol::protocol::GuardianAssessmentStatus::Denied => { + GuardianApprovalReviewStatus::Denied + } + codex_protocol::protocol::GuardianAssessmentStatus::Aborted => { + GuardianApprovalReviewStatus::Aborted + } + }, + risk_score: assessment.risk_score, + risk_level: assessment.risk_level.map(Into::into), + rationale: assessment.rationale.clone(), + }; + let action = assessment.action.clone().into(); + match assessment.status { + codex_protocol::protocol::GuardianAssessmentStatus::InProgress => { + ServerNotification::ItemGuardianApprovalReviewStarted( + ItemGuardianApprovalReviewStartedNotification { + thread_id: conversation_id.to_string(), + turn_id, + target_item_id: assessment.id.clone(), + review, + action, + }, + ) + } + codex_protocol::protocol::GuardianAssessmentStatus::Approved + | codex_protocol::protocol::GuardianAssessmentStatus::Denied + | codex_protocol::protocol::GuardianAssessmentStatus::Aborted => { + ServerNotification::ItemGuardianApprovalReviewCompleted( + ItemGuardianApprovalReviewCompletedNotification { + thread_id: conversation_id.to_string(), + turn_id, + target_item_id: assessment.id.clone(), + review, + action, + }, + ) + } + } +} + +pub fn convert_patch_changes(changes: &HashMap) -> Vec { + let mut converted: Vec = changes + .iter() + .map(|(path, change)| FileUpdateChange { + path: path.to_string_lossy().into_owned(), + kind: map_patch_change_kind(change), + diff: format_file_change_diff(change), + }) + .collect(); + converted.sort_by(|a, b| a.path.cmp(&b.path)); + converted +} + +fn map_patch_change_kind(change: &FileChange) -> PatchChangeKind { + match change { + FileChange::Add { .. } => PatchChangeKind::Add, + FileChange::Delete { .. } => PatchChangeKind::Delete, + FileChange::Update { move_path, .. } => PatchChangeKind::Update { + move_path: move_path.clone(), + }, + } +} + +fn format_file_change_diff(change: &FileChange) -> String { + match change { + FileChange::Add { content } => content.clone(), + FileChange::Delete { content } => content.clone(), + FileChange::Update { + unified_diff, + move_path, + } => { + if let Some(path) = move_path { + format!("{unified_diff}\n\nMoved to: {}", path.display()) + } else { + unified_diff.clone() + } + } + } +} diff --git a/codex-rs/app-server-protocol/src/protocol/mod.rs b/codex-rs/app-server-protocol/src/protocol/mod.rs index 1e0410f4b9..4179d361c7 100644 --- a/codex-rs/app-server-protocol/src/protocol/mod.rs +++ b/codex-rs/app-server-protocol/src/protocol/mod.rs @@ -2,6 +2,7 @@ // Exposes protocol pieces used by `lib.rs` via `pub use protocol::common::*;`. pub mod common; +pub mod item_builders; mod mappers; mod serde_helpers; pub mod thread_history; 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 48fa56d687..27d393629b 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1,16 +1,18 @@ +use crate::protocol::item_builders::build_command_execution_begin_item; +use crate::protocol::item_builders::build_command_execution_end_item; +use crate::protocol::item_builders::build_file_change_approval_request_item; +use crate::protocol::item_builders::build_file_change_begin_item; +use crate::protocol::item_builders::build_file_change_end_item; +use crate::protocol::item_builders::build_item_from_guardian_event; use crate::protocol::v2::CollabAgentState; use crate::protocol::v2::CollabAgentTool; use crate::protocol::v2::CollabAgentToolCallStatus; -use crate::protocol::v2::CommandAction; use crate::protocol::v2::CommandExecutionStatus; use crate::protocol::v2::DynamicToolCallOutputContentItem; use crate::protocol::v2::DynamicToolCallStatus; -use crate::protocol::v2::FileUpdateChange; use crate::protocol::v2::McpToolCallError; use crate::protocol::v2::McpToolCallResult; use crate::protocol::v2::McpToolCallStatus; -use crate::protocol::v2::PatchApplyStatus; -use crate::protocol::v2::PatchChangeKind; use crate::protocol::v2::ThreadItem; use crate::protocol::v2::Turn; use crate::protocol::v2::TurnError as V2TurnError; @@ -31,6 +33,8 @@ use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandBeginEvent; use codex_protocol::protocol::ExecCommandEndEvent; +use codex_protocol::protocol::GuardianAssessmentEvent; +use codex_protocol::protocol::GuardianAssessmentStatus; use codex_protocol::protocol::ImageGenerationBeginEvent; use codex_protocol::protocol::ImageGenerationEndEvent; use codex_protocol::protocol::ItemCompletedEvent; @@ -53,6 +57,14 @@ use std::collections::HashMap; use tracing::warn; use uuid::Uuid; +#[cfg(test)] +use crate::protocol::v2::CommandAction; +#[cfg(test)] +use crate::protocol::v2::FileUpdateChange; +#[cfg(test)] +use crate::protocol::v2::PatchApplyStatus; +#[cfg(test)] +use crate::protocol::v2::PatchChangeKind; #[cfg(test)] use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus; #[cfg(test)] @@ -149,6 +161,7 @@ impl ThreadHistoryBuilder { EventMsg::WebSearchEnd(payload) => self.handle_web_search_end(payload), EventMsg::ExecCommandBegin(payload) => self.handle_exec_command_begin(payload), EventMsg::ExecCommandEnd(payload) => self.handle_exec_command_end(payload), + EventMsg::GuardianAssessment(payload) => self.handle_guardian_assessment(payload), EventMsg::ApplyPatchApprovalRequest(payload) => { self.handle_apply_patch_approval_request(payload) } @@ -375,57 +388,12 @@ impl ThreadHistoryBuilder { } fn handle_exec_command_begin(&mut self, payload: &ExecCommandBeginEvent) { - let command = shlex::try_join(payload.command.iter().map(String::as_str)) - .unwrap_or_else(|_| payload.command.join(" ")); - let command_actions = payload - .parsed_cmd - .iter() - .cloned() - .map(CommandAction::from) - .collect(); - let item = ThreadItem::CommandExecution { - id: payload.call_id.clone(), - command, - cwd: payload.cwd.clone(), - process_id: payload.process_id.clone(), - source: payload.source.into(), - status: CommandExecutionStatus::InProgress, - command_actions, - aggregated_output: None, - exit_code: None, - duration_ms: None, - }; + let item = build_command_execution_begin_item(payload); self.upsert_item_in_turn_id(&payload.turn_id, item); } fn handle_exec_command_end(&mut self, payload: &ExecCommandEndEvent) { - let status: CommandExecutionStatus = (&payload.status).into(); - let duration_ms = i64::try_from(payload.duration.as_millis()).unwrap_or(i64::MAX); - let aggregated_output = if payload.aggregated_output.is_empty() { - None - } else { - Some(payload.aggregated_output.clone()) - }; - let command = shlex::try_join(payload.command.iter().map(String::as_str)) - .unwrap_or_else(|_| payload.command.join(" ")); - let command_actions = payload - .parsed_cmd - .iter() - .cloned() - .map(CommandAction::from) - .collect(); - let item = ThreadItem::CommandExecution { - id: payload.call_id.clone(), - command, - cwd: payload.cwd.clone(), - process_id: payload.process_id.clone(), - source: payload.source.into(), - status, - command_actions, - aggregated_output, - exit_code: Some(payload.exit_code), - duration_ms: Some(duration_ms), - }; + let item = build_command_execution_end_item(payload); // Command completions can arrive out of order. Unified exec may return // while a PTY is still running, then emit ExecCommandEnd later from a // background exit watcher when that process finally exits. By then, a @@ -434,12 +402,26 @@ impl ThreadHistoryBuilder { self.upsert_item_in_turn_id(&payload.turn_id, item); } - fn handle_apply_patch_approval_request(&mut self, payload: &ApplyPatchApprovalRequestEvent) { - let item = ThreadItem::FileChange { - id: payload.call_id.clone(), - changes: convert_patch_changes(&payload.changes), - status: PatchApplyStatus::InProgress, + fn handle_guardian_assessment(&mut self, payload: &GuardianAssessmentEvent) { + let status = match payload.status { + GuardianAssessmentStatus::InProgress => CommandExecutionStatus::InProgress, + GuardianAssessmentStatus::Denied | GuardianAssessmentStatus::Aborted => { + CommandExecutionStatus::Declined + } + GuardianAssessmentStatus::Approved => return, }; + let Some(item) = build_item_from_guardian_event(payload, status) else { + return; + }; + if payload.turn_id.is_empty() { + self.upsert_item_in_current_turn(item); + } else { + self.upsert_item_in_turn_id(&payload.turn_id, item); + } + } + + fn handle_apply_patch_approval_request(&mut self, payload: &ApplyPatchApprovalRequestEvent) { + let item = build_file_change_approval_request_item(payload); if payload.turn_id.is_empty() { self.upsert_item_in_current_turn(item); } else { @@ -448,11 +430,7 @@ impl ThreadHistoryBuilder { } fn handle_patch_apply_begin(&mut self, payload: &PatchApplyBeginEvent) { - let item = ThreadItem::FileChange { - id: payload.call_id.clone(), - changes: convert_patch_changes(&payload.changes), - status: PatchApplyStatus::InProgress, - }; + let item = build_file_change_begin_item(payload); if payload.turn_id.is_empty() { self.upsert_item_in_current_turn(item); } else { @@ -461,12 +439,7 @@ impl ThreadHistoryBuilder { } fn handle_patch_apply_end(&mut self, payload: &PatchApplyEndEvent) { - let status: PatchApplyStatus = (&payload.status).into(); - let item = ThreadItem::FileChange { - id: payload.call_id.clone(), - changes: convert_patch_changes(&payload.changes), - status, - }; + let item = build_file_change_end_item(payload); if payload.turn_id.is_empty() { self.upsert_item_in_current_turn(item); } else { @@ -547,6 +520,7 @@ impl ThreadHistoryBuilder { Some(McpToolCallResult { content: value.content.clone(), structured_content: value.structured_content.clone(), + meta: value.meta.clone(), }), None, ), @@ -891,22 +865,29 @@ impl ThreadHistoryBuilder { } fn handle_turn_aborted(&mut self, payload: &TurnAbortedEvent) { + let apply_abort = |turn: &mut PendingTurn| { + turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; + }; if let Some(turn_id) = payload.turn_id.as_deref() { // Prefer an exact ID match so we interrupt the turn explicitly targeted by the event. if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); return; } if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) { turn.status = TurnStatus::Interrupted; + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } } // If the event has no ID (or refers to an unknown turn), fall back to the active turn. if let Some(turn) = self.current_turn.as_mut() { - turn.status = TurnStatus::Interrupted; + apply_abort(turn); } } @@ -915,15 +896,18 @@ impl ThreadHistoryBuilder { self.current_turn = Some( self.new_turn(Some(payload.turn_id.clone())) .with_status(TurnStatus::InProgress) + .with_started_at(payload.started_at) .opened_explicitly(), ); } fn handle_turn_complete(&mut self, payload: &TurnCompleteEvent) { - let mark_completed = |status: &mut TurnStatus| { - if matches!(*status, TurnStatus::Completed | TurnStatus::InProgress) { - *status = TurnStatus::Completed; + let mark_completed = |turn: &mut PendingTurn| { + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; }; // Prefer an exact ID match from the active turn and then close it. @@ -932,7 +916,7 @@ impl ThreadHistoryBuilder { .as_mut() .filter(|turn| turn.id == payload.turn_id) { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); return; } @@ -942,13 +926,17 @@ impl ThreadHistoryBuilder { .iter_mut() .find(|turn| turn.id == payload.turn_id) { - mark_completed(&mut turn.status); + if matches!(turn.status, TurnStatus::Completed | TurnStatus::InProgress) { + turn.status = TurnStatus::Completed; + } + turn.completed_at = payload.completed_at; + turn.duration_ms = payload.duration_ms; return; } // If the completion event cannot be matched, apply it to the active turn. if let Some(current_turn) = self.current_turn.as_mut() { - mark_completed(&mut current_turn.status); + mark_completed(current_turn); self.finish_current_turn(); } } @@ -981,7 +969,7 @@ impl ThreadHistoryBuilder { if turn.items.is_empty() && !turn.opened_explicitly && !turn.saw_compaction { return; } - self.turns.push(turn.into()); + self.turns.push(Turn::from(turn)); } } @@ -991,6 +979,9 @@ impl ThreadHistoryBuilder { items: Vec::new(), error: None, status: TurnStatus::Completed, + started_at: None, + completed_at: None, + duration_ms: None, opened_explicitly: false, saw_compaction: false, rollout_start_index: self.current_rollout_index, @@ -1076,21 +1067,6 @@ fn render_review_output_text(output: &ReviewOutputEvent) -> String { } } -pub fn convert_patch_changes( - changes: &HashMap, -) -> Vec { - let mut converted: Vec = changes - .iter() - .map(|(path, change)| FileUpdateChange { - path: path.to_string_lossy().into_owned(), - kind: map_patch_change_kind(change), - diff: format_file_change_diff(change), - }) - .collect(); - converted.sort_by(|a, b| a.path.cmp(&b.path)); - converted -} - fn convert_dynamic_tool_content_items( items: &[codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem], ) -> Vec { @@ -1108,33 +1084,6 @@ fn convert_dynamic_tool_content_items( .collect() } -fn map_patch_change_kind(change: &codex_protocol::protocol::FileChange) -> PatchChangeKind { - match change { - codex_protocol::protocol::FileChange::Add { .. } => PatchChangeKind::Add, - codex_protocol::protocol::FileChange::Delete { .. } => PatchChangeKind::Delete, - codex_protocol::protocol::FileChange::Update { move_path, .. } => PatchChangeKind::Update { - move_path: move_path.clone(), - }, - } -} - -fn format_file_change_diff(change: &codex_protocol::protocol::FileChange) -> String { - match change { - codex_protocol::protocol::FileChange::Add { content } => content.clone(), - codex_protocol::protocol::FileChange::Delete { content } => content.clone(), - codex_protocol::protocol::FileChange::Update { - unified_diff, - move_path, - } => { - if let Some(path) = move_path { - format!("{unified_diff}\n\nMoved to: {}", path.display()) - } else { - unified_diff.clone() - } - } - } -} - fn upsert_turn_item(items: &mut Vec, item: ThreadItem) { if let Some(existing_item) = items .iter_mut() @@ -1151,6 +1100,9 @@ struct PendingTurn { items: Vec, error: Option, status: TurnStatus, + started_at: Option, + completed_at: Option, + duration_ms: Option, /// True when this turn originated from an explicit `turn_started`/`turn_complete` /// boundary, so we preserve it even if it has no renderable items. opened_explicitly: bool, @@ -1171,6 +1123,11 @@ impl PendingTurn { self.status = status; self } + + fn with_started_at(mut self, started_at: Option) -> Self { + self.started_at = started_at; + self + } } impl From for Turn { @@ -1180,6 +1137,9 @@ impl From for Turn { items: value.items, error: value.error, status: value.status, + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1191,6 +1151,9 @@ impl From<&PendingTurn> for Turn { items: value.items.clone(), error: value.error.clone(), status: value.status.clone(), + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, } } } @@ -1205,6 +1168,7 @@ mod tests { use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::items::UserMessageItem as CoreUserMessageItem; use codex_protocol::items::build_hook_prompt_message; + use codex_protocol::mcp::CallToolResult; use codex_protocol::models::MessagePhase as CoreMessagePhase; use codex_protocol::models::WebSearchAction as CoreWebSearchAction; use codex_protocol::parse_command::ParsedCommand; @@ -1342,6 +1306,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1362,6 +1327,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1414,6 +1381,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -1433,6 +1401,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -1444,6 +1414,9 @@ mod tests { id: "turn-image".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -1533,6 +1506,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Let's try again".into(), @@ -1730,6 +1705,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1748,6 +1724,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -1784,6 +1762,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1884,11 +1863,73 @@ mod tests { ); } + #[test] + fn reconstructs_mcp_tool_result_meta_from_persisted_completion_events() { + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".into(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::McpToolCallEnd(McpToolCallEndEvent { + call_id: "mcp-1".into(), + invocation: McpInvocation { + server: "docs".into(), + tool: "lookup".into(), + arguments: Some(serde_json::json!({"id":"123"})), + }, + duration: Duration::from_millis(8), + result: Ok(CallToolResult { + content: vec![serde_json::json!({ + "type": "text", + "text": "result" + })], + structured_content: Some(serde_json::json!({"id":"123"})), + is_error: Some(false), + meta: Some(serde_json::json!({ + "ui/resourceUri": "ui://widget/lookup.html" + })), + }), + }), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert_eq!( + turns[0].items[0], + ThreadItem::McpToolCall { + id: "mcp-1".into(), + server: "docs".into(), + tool: "lookup".into(), + status: McpToolCallStatus::Completed, + arguments: serde_json::json!({"id":"123"}), + result: Some(McpToolCallResult { + content: vec![serde_json::json!({ + "type": "text", + "text": "result" + })], + structured_content: Some(serde_json::json!({"id":"123"})), + meta: Some(serde_json::json!({ + "ui/resourceUri": "ui://widget/lookup.html" + })), + }), + error: None, + duration_ms: Some(8), + } + ); + } + #[test] fn reconstructs_dynamic_tool_items_from_request_and_response_events() { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -1948,6 +1989,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2030,11 +2072,144 @@ mod tests { ); } + #[test] + fn reconstructs_declined_guardian_command_item() { + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".into(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "review this command".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::GuardianAssessment(GuardianAssessmentEvent { + id: "guardian-exec".into(), + turn_id: "turn-1".into(), + status: GuardianAssessmentStatus::InProgress, + risk_score: None, + risk_level: None, + rationale: None, + action: serde_json::from_value(serde_json::json!({ + "type": "command", + "source": "shell", + "command": "rm -rf /tmp/guardian", + "cwd": "/tmp", + })) + .expect("guardian action"), + }), + EventMsg::GuardianAssessment(GuardianAssessmentEvent { + id: "guardian-exec".into(), + turn_id: "turn-1".into(), + status: GuardianAssessmentStatus::Denied, + risk_score: Some(97), + risk_level: Some(codex_protocol::protocol::GuardianRiskLevel::High), + rationale: Some("Would delete user data.".into()), + action: serde_json::from_value(serde_json::json!({ + "type": "command", + "source": "shell", + "command": "rm -rf /tmp/guardian", + "cwd": "/tmp", + })) + .expect("guardian action"), + }), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].items.len(), 2); + assert_eq!( + turns[0].items[1], + ThreadItem::CommandExecution { + id: "guardian-exec".into(), + command: "rm -rf /tmp/guardian".into(), + cwd: PathBuf::from("/tmp"), + process_id: None, + source: CommandExecutionSource::Agent, + status: CommandExecutionStatus::Declined, + command_actions: vec![CommandAction::Unknown { + command: "rm -rf /tmp/guardian".into(), + }], + aggregated_output: None, + exit_code: None, + duration_ms: None, + } + ); + } + + #[test] + fn reconstructs_in_progress_guardian_execve_item() { + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".into(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::UserMessage(UserMessageEvent { + message: "run a subcommand".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + }), + EventMsg::GuardianAssessment(GuardianAssessmentEvent { + id: "guardian-execve".into(), + turn_id: "turn-1".into(), + status: GuardianAssessmentStatus::InProgress, + risk_score: None, + risk_level: None, + rationale: None, + action: serde_json::from_value(serde_json::json!({ + "type": "execve", + "source": "shell", + "program": "/bin/rm", + "argv": ["/usr/bin/rm", "-f", "/tmp/file.sqlite"], + "cwd": "/tmp", + })) + .expect("guardian action"), + }), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].items.len(), 2); + assert_eq!( + turns[0].items[1], + ThreadItem::CommandExecution { + id: "guardian-execve".into(), + command: "/bin/rm -f /tmp/file.sqlite".into(), + cwd: PathBuf::from("/tmp"), + process_id: None, + source: CommandExecutionSource::Agent, + status: CommandExecutionStatus::InProgress, + command_actions: vec![CommandAction::Unknown { + command: "/bin/rm -f /tmp/file.sqlite".into(), + }], + aggregated_output: None, + exit_code: None, + duration_ms: None, + } + ); + } + #[test] fn assigns_late_exec_completion_to_original_turn() { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2047,9 +2222,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2081,6 +2259,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2118,6 +2298,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2130,9 +2311,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2164,6 +2348,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2196,6 +2382,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2259,6 +2446,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2321,6 +2509,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2333,9 +2522,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2348,6 +2540,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2357,6 +2551,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2376,6 +2572,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2388,9 +2585,12 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2403,6 +2603,8 @@ mod tests { EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), reason: TurnAbortReason::Replaced, + completed_at: None, + duration_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2428,6 +2630,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2438,6 +2641,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-compact".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2448,6 +2653,9 @@ mod tests { id: "turn-compact".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: Vec::new(), }] ); @@ -2665,6 +2873,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2677,6 +2886,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2696,6 +2907,9 @@ mod tests { id: "turn-a".into(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: None, + duration_ms: None, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { @@ -2712,6 +2926,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), @@ -2730,6 +2945,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, }), ]; @@ -2765,6 +2982,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2778,6 +2996,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; @@ -2808,6 +3028,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), @@ -2823,6 +3044,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 67c4e95856..e0d22bfa24 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -7,8 +7,9 @@ use crate::protocol::common::AuthMode; use codex_experimental_api_macros::ExperimentalApi; use codex_protocol::account::PlanType; use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest; -use codex_protocol::approvals::ExecApprovalRequestSkillMetadata as CoreExecApprovalRequestSkillMetadata; use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; +use codex_protocol::approvals::GuardianAssessmentAction as CoreGuardianAssessmentAction; +use codex_protocol::approvals::GuardianCommandSource as CoreGuardianCommandSource; use codex_protocol::approvals::NetworkApprovalContext as CoreNetworkApprovalContext; use codex_protocol::approvals::NetworkApprovalProtocol as CoreNetworkApprovalProtocol; use codex_protocol::approvals::NetworkPolicyAmendment as CoreNetworkPolicyAmendment; @@ -28,15 +29,12 @@ use codex_protocol::config_types::WebSearchToolConfig; use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::mcp::Resource as McpResource; +pub use codex_protocol::mcp::ResourceContent as McpResourceContent; use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate; use codex_protocol::mcp::Tool as McpTool; use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; -use codex_protocol::models::MacOsAutomationPermission as CoreMacOsAutomationPermission; -use codex_protocol::models::MacOsContactsPermission as CoreMacOsContactsPermission; -use codex_protocol::models::MacOsPreferencesPermission as CoreMacOsPreferencesPermission; -use codex_protocol::models::MacOsSeatbeltProfileExtensions as CoreMacOsSeatbeltProfileExtensions; use codex_protocol::models::MessagePhase; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; use codex_protocol::models::PermissionProfile as CorePermissionProfile; @@ -377,7 +375,7 @@ v2_enum_from_core!( v2_enum_from_core!( pub enum HookEventName from CoreHookEventName { - PreToolUse, SessionStart, UserPromptSubmit, Stop + PreToolUse, PostToolUse, SessionStart, UserPromptSubmit, Stop } ); @@ -853,6 +851,8 @@ pub struct ConfigReadResponse { pub struct ConfigRequirements { #[experimental(nested)] pub allowed_approval_policies: Option>, + #[experimental("configRequirements/read.allowedApprovalsReviewers")] + pub allowed_approvals_reviewers: Option>, pub allowed_sandbox_modes: Option>, pub allowed_web_search_modes: Option>, pub feature_requirements: Option>, @@ -871,10 +871,37 @@ pub struct NetworkRequirements { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_all_unix_sockets: Option, + /// Canonical network permission map for `experimental_network`. + pub domains: Option>, + /// When true, only managed allowlist entries are respected while managed + /// network enforcement is active. + pub managed_allowed_domains_only: Option, + /// Legacy compatibility view derived from `domains`. pub allowed_domains: Option>, + /// Legacy compatibility view derived from `domains`. pub denied_domains: Option>, + /// Canonical unix socket permission map for `experimental_network`. + pub unix_sockets: Option>, + /// Legacy compatibility view derived from `unix_sockets`. pub allow_unix_sockets: Option>, pub allow_local_binding: Option, + pub danger_full_access_denylist_only: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum NetworkDomainPermission { + Allow, + Deny, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum NetworkUnixSocketPermission { + Allow, + None, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] @@ -1086,47 +1113,6 @@ impl From for CoreFileSystemPermissions { } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AdditionalMacOsPermissions { - pub preferences: CoreMacOsPreferencesPermission, - pub automations: CoreMacOsAutomationPermission, - pub launch_services: bool, - pub accessibility: bool, - pub calendar: bool, - pub reminders: bool, - pub contacts: CoreMacOsContactsPermission, -} - -impl From for AdditionalMacOsPermissions { - fn from(value: CoreMacOsSeatbeltProfileExtensions) -> Self { - Self { - preferences: value.macos_preferences, - automations: value.macos_automation, - launch_services: value.macos_launch_services, - accessibility: value.macos_accessibility, - calendar: value.macos_calendar, - reminders: value.macos_reminders, - contacts: value.macos_contacts, - } - } -} - -impl From for CoreMacOsSeatbeltProfileExtensions { - fn from(value: AdditionalMacOsPermissions) -> Self { - Self { - macos_preferences: value.preferences, - macos_automation: value.automations, - macos_launch_services: value.launch_services, - macos_accessibility: value.accessibility, - macos_calendar: value.calendar, - macos_reminders: value.reminders, - macos_contacts: value.contacts, - } - } -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -1183,7 +1169,6 @@ impl From for CoreRequestPermissionProfile { pub struct AdditionalPermissionProfile { pub network: Option, pub file_system: Option, - pub macos: Option, } impl From for AdditionalPermissionProfile { @@ -1191,7 +1176,6 @@ impl From for AdditionalPermissionProfile { Self { network: value.network.map(AdditionalNetworkPermissions::from), file_system: value.file_system.map(AdditionalFileSystemPermissions::from), - macos: value.macos.map(AdditionalMacOsPermissions::from), } } } @@ -1201,7 +1185,6 @@ impl From for CorePermissionProfile { Self { network: value.network.map(CoreNetworkPermissions::from), file_system: value.file_system.map(CoreFileSystemPermissions::from), - macos: value.macos.map(CoreMacOsSeatbeltProfileExtensions::from), } } } @@ -1223,7 +1206,6 @@ impl From for CorePermissionProfile { Self { network: value.network.map(CoreNetworkPermissions::from), file_system: value.file_system.map(CoreFileSystemPermissions::from), - macos: None, } } } @@ -1613,6 +1595,9 @@ pub enum LoginAccountParams { #[serde(rename = "chatgpt")] #[ts(rename = "chatgpt")] Chatgpt, + #[serde(rename = "chatgptDeviceCode")] + #[ts(rename = "chatgptDeviceCode")] + ChatgptDeviceCode, /// [UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. /// The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have. #[experimental("account/login/start.chatgptAuthTokens")] @@ -1650,6 +1635,17 @@ pub enum LoginAccountResponse { /// URL the client should open in a browser to initiate the OAuth flow. auth_url: String, }, + #[serde(rename = "chatgptDeviceCode", rename_all = "camelCase")] + #[ts(rename = "chatgptDeviceCode", rename_all = "camelCase")] + ChatgptDeviceCode { + // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. + // Convert to/from UUIDs at the application layer as needed. + login_id: String, + /// URL the client should open in a browser to complete device code authorization. + verification_url: String, + /// One-time code the user must enter after signing in. + user_code: String, + }, #[serde(rename = "chatgptAuthTokens", rename_all = "camelCase")] #[ts(rename = "chatgptAuthTokens", rename_all = "camelCase")] ChatgptAuthTokens {}, @@ -1955,6 +1951,18 @@ pub struct ListMcpServerStatusParams { /// Optional page size; defaults to a server-defined value. #[ts(optional = nullable)] pub limit: Option, + /// Controls how much MCP inventory data to fetch for each server. + /// Defaults to `Full` when omitted. + #[ts(optional = nullable)] + pub detail: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum McpServerStatusDetail { + Full, + ToolsAndAuthOnly, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -1978,6 +1986,22 @@ pub struct ListMcpServerStatusResponse { pub next_cursor: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpResourceReadParams { + pub thread_id: String, + pub server: String, + pub uri: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpResourceReadResponse { + pub contents: Vec, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -2325,17 +2349,17 @@ pub struct FsCopyResponse {} #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct FsWatchParams { + /// Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`. + pub watch_id: String, /// Absolute file or directory path to watch. pub path: AbsolutePathBuf, } -/// Created watch handle returned by `fs/watch`. +/// Successful response for `fs/watch`. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct FsWatchResponse { - /// Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`. - pub watch_id: String, /// Canonicalized path associated with the watch. pub path: AbsolutePathBuf, } @@ -2345,7 +2369,7 @@ pub struct FsWatchResponse { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct FsUnwatchParams { - /// Watch identifier returned by `fs/watch`. + /// Watch identifier previously provided to `fs/watch`. pub watch_id: String, } @@ -2360,7 +2384,7 @@ pub struct FsUnwatchResponse {} #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct FsChangedNotification { - /// Watch identifier returned by `fs/watch`. + /// Watch identifier previously provided to `fs/watch`. pub watch_id: String, /// File or directory paths associated with this event. pub changed_paths: Vec, @@ -2579,10 +2603,22 @@ pub struct ThreadStartParams { pub config: Option>, #[ts(optional = nullable)] pub service_name: 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)] - pub base_instructions: Option, + pub base_instructions: 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)] - pub developer_instructions: Option, + pub developer_instructions: Option>, #[ts(optional = nullable)] pub personality: Option, #[ts(optional = nullable)] @@ -2697,10 +2733,22 @@ pub struct ThreadResumeParams { pub sandbox: Option, #[ts(optional = nullable)] pub config: 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)] - pub base_instructions: Option, + pub base_instructions: 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)] - pub developer_instructions: Option, + pub developer_instructions: Option>, #[ts(optional = nullable)] pub personality: Option, /// If true, persist additional rollout EventMsg variants required to @@ -2774,10 +2822,22 @@ pub struct ThreadForkParams { pub sandbox: Option, #[ts(optional = nullable)] pub config: 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)] - pub base_instructions: Option, + pub base_instructions: 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)] - pub developer_instructions: Option, + pub developer_instructions: Option>, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub ephemeral: bool, /// If true, persist additional rollout EventMsg variants required to @@ -3519,14 +3579,6 @@ impl From for SkillMetadata { } } -impl From for CommandExecutionRequestApprovalSkillMetadata { - fn from(value: CoreExecApprovalRequestSkillMetadata) -> Self { - Self { - path_to_skills_md: value.path_to_skills_md, - } - } -} - impl From for SkillInterface { fn from(value: CoreSkillInterface) -> Self { Self { @@ -3590,6 +3642,8 @@ impl From for SkillErrorInfo { #[ts(export_to = "v2/")] pub struct Thread { pub id: String, + /// Source thread id when this thread was created by forking another thread. + pub forked_from_id: Option, /// Usually the first user message in the thread, if available. pub preview: String, /// Whether the thread is ephemeral and should not be materialized on disk. @@ -3705,6 +3759,15 @@ pub struct Turn { pub status: TurnStatus, /// Only populated when the Turn's status is failed. pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[ts(type = "number | null")] + pub started_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[ts(type = "number | null")] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[ts(type = "number | null")] + pub duration_ms: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] @@ -4463,14 +4526,237 @@ impl From for GuardianRiskLevel { #[ts(export_to = "v2/")] pub struct GuardianApprovalReview { pub status: GuardianApprovalReviewStatus, - #[serde(alias = "risk_score")] #[ts(type = "number | null")] pub risk_score: Option, - #[serde(alias = "risk_level")] pub risk_level: Option, pub rationale: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum GuardianCommandSource { + Shell, + UnifiedExec, +} + +impl From for GuardianCommandSource { + fn from(value: CoreGuardianCommandSource) -> Self { + match value { + CoreGuardianCommandSource::Shell => Self::Shell, + CoreGuardianCommandSource::UnifiedExec => Self::UnifiedExec, + } + } +} + +impl From for CoreGuardianCommandSource { + fn from(value: GuardianCommandSource) -> Self { + match value { + GuardianCommandSource::Shell => Self::Shell, + GuardianCommandSource::UnifiedExec => Self::UnifiedExec, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianCommandReviewAction { + pub source: GuardianCommandSource, + pub command: String, + pub cwd: PathBuf, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianExecveReviewAction { + pub source: GuardianCommandSource, + pub program: String, + pub argv: Vec, + pub cwd: PathBuf, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianApplyPatchReviewAction { + pub cwd: PathBuf, + pub files: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianNetworkAccessReviewAction { + pub target: String, + pub host: String, + pub protocol: NetworkApprovalProtocol, + pub port: u16, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianMcpToolCallReviewAction { + pub server: String, + pub tool_name: String, + pub connector_id: Option, + pub connector_name: Option, + pub tool_title: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type", rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum GuardianApprovalReviewAction { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Command { + source: GuardianCommandSource, + command: String, + cwd: PathBuf, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Execve { + source: GuardianCommandSource, + program: String, + argv: Vec, + cwd: PathBuf, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ApplyPatch { cwd: PathBuf, files: Vec }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + NetworkAccess { + target: String, + host: String, + protocol: NetworkApprovalProtocol, + port: u16, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + McpToolCall { + server: String, + tool_name: String, + connector_id: Option, + connector_name: Option, + tool_title: Option, + }, +} + +impl From for GuardianApprovalReviewAction { + fn from(value: CoreGuardianAssessmentAction) -> Self { + match value { + CoreGuardianAssessmentAction::Command { + source, + command, + cwd, + } => Self::Command { + source: source.into(), + command, + cwd, + }, + CoreGuardianAssessmentAction::Execve { + source, + program, + argv, + cwd, + } => Self::Execve { + source: source.into(), + program, + argv, + cwd, + }, + CoreGuardianAssessmentAction::ApplyPatch { cwd, files } => { + Self::ApplyPatch { cwd, files } + } + CoreGuardianAssessmentAction::NetworkAccess { + target, + host, + protocol, + port, + } => Self::NetworkAccess { + target, + host, + protocol: protocol.into(), + port, + }, + CoreGuardianAssessmentAction::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + } => Self::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + }, + } + } +} + +impl From for CoreGuardianAssessmentAction { + fn from(value: GuardianApprovalReviewAction) -> Self { + match value { + GuardianApprovalReviewAction::Command { + source, + command, + cwd, + } => Self::Command { + source: source.into(), + command, + cwd, + }, + GuardianApprovalReviewAction::Execve { + source, + program, + argv, + cwd, + } => Self::Execve { + source: source.into(), + program, + argv, + cwd, + }, + GuardianApprovalReviewAction::ApplyPatch { cwd, files } => { + Self::ApplyPatch { cwd, files } + } + GuardianApprovalReviewAction::NetworkAccess { + target, + host, + protocol, + port, + } => Self::NetworkAccess { + target, + host, + protocol: protocol.to_core(), + port, + }, + GuardianApprovalReviewAction::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + } => Self::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + }, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type", rename_all = "camelCase")] @@ -4762,6 +5048,9 @@ pub struct McpToolCallResult { // representations). Using `JsonValue` keeps the payload wire-shaped and easy to export. pub content: Vec, pub structured_content: Option, + #[serde(rename = "_meta")] + #[ts(rename = "_meta")] + pub meta: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -4951,7 +5240,7 @@ pub struct ItemGuardianApprovalReviewStartedNotification { pub turn_id: String, pub target_item_id: String, pub review: GuardianApprovalReview, - pub action: Option, + pub action: GuardianApprovalReviewAction, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -4968,7 +5257,7 @@ pub struct ItemGuardianApprovalReviewCompletedNotification { pub turn_id: String, pub target_item_id: String, pub review: GuardianApprovalReview, - pub action: Option, + pub action: GuardianApprovalReviewAction, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -5240,11 +5529,6 @@ pub struct CommandExecutionRequestApprovalParams { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] pub additional_permissions: Option, - /// Optional skill metadata when the approval was triggered by a skill script. - #[experimental("item/commandExecution/requestApproval.skillMetadata")] - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub skill_metadata: Option, /// Optional proposed execpolicy amendment to allow similar commands without prompting. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] @@ -5266,17 +5550,9 @@ impl CommandExecutionRequestApprovalParams { // We need a generic outbound compatibility design for stripping or // otherwise handling experimental server->client payloads. self.additional_permissions = None; - self.skill_metadata = None; } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecutionRequestApprovalSkillMetadata { - pub path_to_skills_md: PathBuf, -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -6097,10 +6373,8 @@ mod tests { "fileSystem": { "read": ["relative/path"], "write": null - }, - "macos": null + } }, - "skillMetadata": null, "proposedExecpolicyAmendment": null, "proposedNetworkPolicyAmendments": null, "availableDecisions": null @@ -6113,121 +6387,6 @@ mod tests { ); } - #[test] - fn command_execution_request_approval_accepts_macos_automation_bundle_ids_object() { - let params = serde_json::from_value::(json!({ - "threadId": "thr_123", - "turnId": "turn_123", - "itemId": "call_123", - "command": "cat file", - "cwd": "/tmp", - "commandActions": null, - "reason": null, - "networkApprovalContext": null, - "additionalPermissions": { - "network": null, - "fileSystem": null, - "macos": { - "preferences": "read_only", - "automations": { - "bundle_ids": ["com.apple.Notes"] - }, - "launchServices": false, - "accessibility": false, - "calendar": false, - "reminders": false, - "contacts": "read_only" - } - }, - "skillMetadata": null, - "proposedExecpolicyAmendment": null, - "proposedNetworkPolicyAmendments": null, - "availableDecisions": null - })) - .expect("bundle_ids object should deserialize"); - - assert_eq!( - params - .additional_permissions - .and_then(|permissions| permissions.macos) - .map(|macos| (macos.automations, macos.launch_services, macos.contacts)), - Some(( - CoreMacOsAutomationPermission::BundleIds(vec!["com.apple.Notes".to_string(),]), - false, - CoreMacOsContactsPermission::ReadOnly, - )) - ); - } - - #[test] - fn command_execution_request_approval_accepts_macos_reminders_permission() { - let params = serde_json::from_value::(json!({ - "threadId": "thr_123", - "turnId": "turn_123", - "itemId": "call_123", - "command": "cat file", - "cwd": "/tmp", - "commandActions": null, - "reason": null, - "networkApprovalContext": null, - "additionalPermissions": { - "network": null, - "fileSystem": null, - "macos": { - "preferences": "read_only", - "automations": "none", - "launchServices": false, - "accessibility": false, - "calendar": false, - "reminders": true, - "contacts": "none" - } - }, - "skillMetadata": null, - "proposedExecpolicyAmendment": null, - "proposedNetworkPolicyAmendments": null, - "availableDecisions": null - })) - .expect("reminders permission should deserialize"); - - assert_eq!( - params - .additional_permissions - .and_then(|permissions| permissions.macos) - .map(|macos| macos.reminders), - Some(true) - ); - } - - #[test] - fn command_execution_request_approval_accepts_skill_metadata() { - let params = serde_json::from_value::(json!({ - "threadId": "thr_123", - "turnId": "turn_123", - "itemId": "call_123", - "command": "cat file", - "cwd": "/tmp", - "commandActions": null, - "reason": null, - "networkApprovalContext": null, - "additionalPermissions": null, - "skillMetadata": { - "pathToSkillsMd": "/tmp/SKILLS.md" - }, - "proposedExecpolicyAmendment": null, - "proposedNetworkPolicyAmendments": null, - "availableDecisions": null - })) - .expect("skill metadata should deserialize"); - - assert_eq!( - params.skill_metadata, - Some(CommandExecutionRequestApprovalSkillMetadata { - path_to_skills_md: PathBuf::from("/tmp/SKILLS.md"), - }) - ); - } - #[test] fn permissions_request_approval_uses_request_permission_profile() { let read_only_path = if cfg!(windows) { @@ -6385,7 +6544,6 @@ mod tests { .expect("path must be absolute"), ]), }), - macos: None, } ); } @@ -7237,6 +7395,7 @@ mod tests { request_permissions: false, mcp_elicitations: false, }]), + allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, feature_requirements: None, @@ -7636,26 +7795,6 @@ mod tests { ); } - #[test] - fn automatic_approval_review_deserializes_legacy_snake_case_risk_fields() { - let review: GuardianApprovalReview = serde_json::from_value(json!({ - "status": "denied", - "risk_score": 91, - "risk_level": "high", - "rationale": "too risky" - })) - .expect("legacy snake_case automatic review should deserialize"); - assert_eq!( - review, - GuardianApprovalReview { - status: GuardianApprovalReviewStatus::Denied, - risk_score: Some(91), - risk_level: Some(GuardianRiskLevel::High), - rationale: Some("too risky".to_string()), - } - ); - } - #[test] fn automatic_approval_review_deserializes_aborted_status() { let review: GuardianApprovalReview = serde_json::from_value(json!({ @@ -7676,6 +7815,122 @@ mod tests { ); } + #[test] + fn guardian_approval_review_action_round_trips_command_shape() { + let value = json!({ + "type": "command", + "source": "shell", + "command": "rm -rf /tmp/example.sqlite", + "cwd": "/tmp", + }); + let action: GuardianApprovalReviewAction = + serde_json::from_value(value.clone()).expect("guardian review action"); + + assert_eq!( + action, + GuardianApprovalReviewAction::Command { + source: GuardianCommandSource::Shell, + command: "rm -rf /tmp/example.sqlite".to_string(), + cwd: "/tmp".into(), + } + ); + assert_eq!( + serde_json::to_value(&action).expect("serialize guardian review action"), + value + ); + } + + #[test] + fn network_requirements_deserializes_legacy_fields() { + let requirements: NetworkRequirements = serde_json::from_value(json!({ + "allowedDomains": ["api.openai.com"], + "deniedDomains": ["blocked.example.com"], + "allowUnixSockets": ["/tmp/proxy.sock"] + })) + .expect("legacy network requirements should deserialize"); + + assert_eq!( + requirements, + NetworkRequirements { + enabled: None, + http_port: None, + socks_port: None, + allow_upstream_proxy: None, + dangerously_allow_non_loopback_proxy: None, + dangerously_allow_all_unix_sockets: None, + domains: None, + managed_allowed_domains_only: None, + danger_full_access_denylist_only: None, + allowed_domains: Some(vec!["api.openai.com".to_string()]), + denied_domains: Some(vec!["blocked.example.com".to_string()]), + unix_sockets: None, + allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), + allow_local_binding: None, + } + ); + } + + #[test] + fn network_requirements_serializes_canonical_and_legacy_fields() { + let requirements = NetworkRequirements { + enabled: Some(true), + http_port: Some(8080), + socks_port: Some(1080), + allow_upstream_proxy: Some(false), + dangerously_allow_non_loopback_proxy: Some(false), + dangerously_allow_all_unix_sockets: Some(true), + domains: Some(BTreeMap::from([ + ("api.openai.com".to_string(), NetworkDomainPermission::Allow), + ( + "blocked.example.com".to_string(), + NetworkDomainPermission::Deny, + ), + ])), + managed_allowed_domains_only: Some(true), + danger_full_access_denylist_only: Some(true), + allowed_domains: Some(vec!["api.openai.com".to_string()]), + denied_domains: Some(vec!["blocked.example.com".to_string()]), + unix_sockets: Some(BTreeMap::from([ + ( + "/tmp/proxy.sock".to_string(), + NetworkUnixSocketPermission::Allow, + ), + ( + "/tmp/ignored.sock".to_string(), + NetworkUnixSocketPermission::None, + ), + ])), + allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), + allow_local_binding: Some(true), + }; + + assert_eq!( + serde_json::to_value(requirements).expect("network requirements should serialize"), + json!({ + "enabled": true, + "httpPort": 8080, + "socksPort": 1080, + "allowUpstreamProxy": false, + "dangerouslyAllowNonLoopbackProxy": false, + "dangerouslyAllowAllUnixSockets": true, + "domains": { + "api.openai.com": "allow", + "blocked.example.com": "deny" + }, + "managedAllowedDomainsOnly": true, + "dangerFullAccessDenylistOnly": true, + "allowedDomains": ["api.openai.com"], + "deniedDomains": ["blocked.example.com"], + "unixSockets": { + "/tmp/ignored.sock": "none", + "/tmp/proxy.sock": "allow" + }, + "allowUnixSockets": ["/tmp/proxy.sock"], + "allowLocalBinding": true + }) + ); + } + #[test] fn core_turn_item_into_thread_item_converts_supported_variants() { let user_item = TurnItem::UserMessage(UserMessageItem { @@ -8109,6 +8364,35 @@ mod tests { assert_eq!(serialized_without_override.get("serviceTier"), None); } + #[test] + fn thread_start_params_preserve_explicit_null_instructions() { + let params: ThreadStartParams = serde_json::from_value(json!({ + "baseInstructions": null, + "developerInstructions": null, + })) + .expect("params should deserialize"); + assert_eq!(params.base_instructions, Some(None)); + assert_eq!(params.developer_instructions, Some(None)); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("baseInstructions"), + Some(&serde_json::Value::Null) + ); + assert_eq!( + serialized.get("developerInstructions"), + Some(&serde_json::Value::Null) + ); + + let serialized_without_override = + serde_json::to_value(ThreadStartParams::default()).expect("params should serialize"); + assert_eq!(serialized_without_override.get("baseInstructions"), None); + assert_eq!( + serialized_without_override.get("developerInstructions"), + None + ); + } + #[test] fn turn_start_params_preserve_explicit_null_service_tier() { let params: TurnStartParams = serde_json::from_value(json!({ diff --git a/codex-rs/app-server-protocol/tests/schema_fixtures.rs b/codex-rs/app-server-protocol/tests/schema_fixtures.rs index fbe1fd4408..20823466e7 100644 --- a/codex-rs/app-server-protocol/tests/schema_fixtures.rs +++ b/codex-rs/app-server-protocol/tests/schema_fixtures.rs @@ -23,7 +23,7 @@ fn typescript_schema_fixtures_match_generated() -> Result<()> { #[test] fn json_schema_fixtures_match_generated() -> Result<()> { assert_schema_fixtures_match_generated("json", |output_dir| { - generate_json_with_experimental(output_dir, false) + generate_json_with_experimental(output_dir, /*experimental_api*/ false) }) } diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index a479f96529..dd548012a0 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -225,7 +225,11 @@ enum CliCommand { abort_on: Option, }, /// Trigger the ChatGPT login flow and wait for completion. - TestLogin, + TestLogin { + /// Use the device-code login flow instead of the browser callback flow. + #[arg(long, default_value_t = false)] + device_code: bool, + }, /// Fetch the current account rate limits from the Codex app-server. GetAccountRateLimits, /// List the available models from the Codex app-server. @@ -372,10 +376,10 @@ pub async fn run() -> Result<()> { ) .await } - CliCommand::TestLogin => { + CliCommand::TestLogin { device_code } => { ensure_dynamic_tools_unused(&dynamic_tools, "test-login")?; let endpoint = resolve_endpoint(codex_bin, url)?; - test_login(&endpoint, &config_overrides).await + test_login(&endpoint, &config_overrides, device_code).await } CliCommand::GetAccountRateLimits => { ensure_dynamic_tools_unused(&dynamic_tools, "get-account-rate-limits")?; @@ -1028,17 +1032,38 @@ async fn send_follow_up_v2( .await } -async fn test_login(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> { +async fn test_login( + endpoint: &Endpoint, + config_overrides: &[String], + device_code: bool, +) -> Result<()> { with_client("test-login", endpoint, config_overrides, |client| { let initialize = client.initialize()?; println!("< initialize response: {initialize:?}"); - let login_response = client.login_account_chatgpt()?; - println!("< account/login/start response: {login_response:?}"); - let LoginAccountResponse::Chatgpt { login_id, auth_url } = login_response else { - bail!("expected chatgpt login response"); + let login_response = if device_code { + client.login_account_chatgpt_device_code()? + } else { + client.login_account_chatgpt()? + }; + println!("< account/login/start response: {login_response:?}"); + let login_id = match login_response { + LoginAccountResponse::Chatgpt { login_id, auth_url } => { + println!("Open the following URL in your browser to continue:\n{auth_url}"); + login_id + } + LoginAccountResponse::ChatgptDeviceCode { + login_id, + verification_url, + user_code, + } => { + println!( + "Open the following URL and enter the code to continue:\n{verification_url}\n\nCode: {user_code}" + ); + login_id + } + _ => bail!("expected chatgpt login response"), }; - println!("Open the following URL in your browser to continue:\n{auth_url}"); let completion = client.wait_for_account_login_completion(&login_id)?; println!("< account/login/completed notification: {completion:?}"); @@ -1590,6 +1615,16 @@ impl CodexClient { self.send_request(request, request_id, "account/login/start") } + fn login_account_chatgpt_device_code(&mut self) -> Result { + let request_id = self.request_id(); + let request = ClientRequest::LoginAccount { + request_id: request_id.clone(), + params: codex_app_server_protocol::LoginAccountParams::ChatgptDeviceCode, + }; + + self.send_request(request, request_id, "account/login/start") + } + fn get_account_rate_limits(&mut self) -> Result { let request_id = self.request_id(); let request = ClientRequest::GetAccountRateLimits { @@ -1917,7 +1952,6 @@ impl CodexClient { cwd, command_actions, additional_permissions, - skill_metadata, proposed_execpolicy_amendment, proposed_network_policy_amendments, available_decisions, @@ -1952,9 +1986,6 @@ impl CodexClient { if let Some(additional_permissions) = additional_permissions.as_ref() { println!("< additional permissions: {additional_permissions:?}"); } - if let Some(skill_metadata) = skill_metadata.as_ref() { - println!("< skill metadata: {skill_metadata:?}"); - } if let Some(execpolicy_amendment) = proposed_execpolicy_amendment.as_ref() { println!("< proposed execpolicy amendment: {execpolicy_amendment:?}"); } diff --git a/codex-rs/app-server/BUILD.bazel b/codex-rs/app-server/BUILD.bazel index 5f1ba790d5..872f533be1 100644 --- a/codex-rs/app-server/BUILD.bazel +++ b/codex-rs/app-server/BUILD.bazel @@ -3,5 +3,6 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "app-server", crate_name = "codex_app_server", + integration_test_timeout = "long", test_tags = ["no-sandbox"], ) diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index f54bb7ad03..5dc3a31485 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -29,8 +29,10 @@ axum = { workspace = true, default-features = false, features = [ "tokio", "ws", ] } +codex-analytics = { workspace = true } codex-arg0 = { workspace = true } codex-cloud-requirements = { workspace = true } +codex-config = { workspace = true } codex-core = { workspace = true } codex-exec-server = { workspace = true } codex-features = { workspace = true } @@ -43,18 +45,24 @@ codex-backend-client = { workspace = true } codex-file-search = { workspace = true } codex-chatgpt = { workspace = true } codex-login = { workspace = true } +codex-mcp = { workspace = true } +codex-models-manager = { workspace = true } codex-protocol = { workspace = true } codex-app-server-protocol = { workspace = true } codex-feedback = { workspace = true } codex-rmcp-client = { workspace = true } +codex-rollout = { workspace = true } codex-sandboxing = { workspace = true } codex-state = { workspace = true } +codex-tools = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-json-to-toml = { workspace = true } +codex-utils-rustls-provider = { workspace = true } chrono = { workspace = true } clap = { workspace = true, features = ["derive"] } constant_time_eq = { workspace = true } futures = { workspace = true } +gethostname = { workspace = true } hmac = { workspace = true } jsonwebtoken = { workspace = true } owo-colors = { workspace = true, features = ["supports-colors"] } @@ -75,6 +83,7 @@ tokio-util = { workspace = true } tokio-tungstenite = { workspace = true } tracing = { workspace = true, features = ["log"] } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "json"] } +url = { workspace = true } uuid = { workspace = true, features = ["serde", "v7"] } [dev-dependencies] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a1856af46d..3aff647766 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -25,6 +25,7 @@ Supported transports: - stdio (`--listen stdio://`, default): newline-delimited JSON (JSONL) - websocket (`--listen ws://IP:PORT`): one JSON-RPC message per websocket text frame (**experimental / unsupported**) +- off (`--listen off`): do not expose a local transport When running with `--listen ws://IP:PORT`, the same listener also serves basic HTTP health probes: @@ -132,9 +133,9 @@ Example with notification opt-out: ## API Overview -- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. +- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. -- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. +- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. @@ -168,7 +169,7 @@ Example with notification opt-out: - `fs/readDirectory` — list direct child entries for an absolute directory path; each entry contains `fileName`, `isDirectory`, and `isFile`, and `fileName` is just the child name, not a path. - `fs/remove` — remove an absolute file or directory tree; `recursive` and `force` default to `true`. - `fs/copy` — copy between absolute paths; directory copies require `recursive: true`. -- `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path; returns a `watchId` and canonicalized `path`. +- `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path and caller-provided `watchId`; returns the canonicalized `path`. - `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`. - `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`. - `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. @@ -186,7 +187,8 @@ Example with notification opt-out: - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. - `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental). - `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. -- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination. +- `mcpServerStatus/list` — enumerate configured MCP servers with their tools and auth status, plus resources/resource templates for `full` detail; supports cursor+limit pagination. If `detail` is omitted, the server defaults to `full`. +- `mcpServer/resource/read` — read a resource from a thread's configured MCP server by `threadId`, `server`, and `uri`, returning text/blob resource `contents`. - `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`. - `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id. - `config/read` — fetch the effective config on disk after resolving config layering. @@ -194,7 +196,7 @@ Example with notification opt-out: - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home). - `config/value/write` — write a single config key/value to the user's config.toml on disk. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads. -- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), `enforceResidency`, and `network` constraints. +- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. ### Example: Start or resume a thread @@ -273,7 +275,7 @@ Experimental API: `thread/start`, `thread/resume`, and `thread/fork` accept `per - `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers. - `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`). - `archived` — when `true`, list archived threads only. When `false` or `null`, list non-archived threads (default). -- `cwd` — restrict results to threads whose session cwd exactly matches this path. +- `cwd` — restrict results to threads whose session cwd exactly matches this path. Relative paths are resolved against the app-server process cwd before matching. - `searchTerm` — restrict results to threads whose extracted title contains this substring (case-sensitive). - Responses include `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available. @@ -814,10 +816,10 @@ All filesystem paths in this section must be absolute. ```json { "method": "fs/watch", "id": 44, "params": { + "watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1", "path": "/Users/me/project/.git/HEAD" } } { "id": 44, "result": { - "watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1", "path": "/Users/me/project/.git/HEAD" } } { "method": "fs/changed", "params": { @@ -913,10 +915,10 @@ All items emit shared lifecycle events: - `item/started` — emits the full `item` when a new unit of work begins so the UI can render it immediately; the `item.id` in this payload matches the `itemId` used by deltas. - `item/completed` — sends the final `item` once that work itself finishes (for example, after a tool call or message completes); treat this as the authoritative execution/result state. -- `item/autoApprovalReview/started` — [UNSTABLE] temporary guardian notification carrying `{threadId, turnId, targetItemId, review, action?}` when guardian approval review begins. This shape is expected to change soon. -- `item/autoApprovalReview/completed` — [UNSTABLE] temporary guardian notification carrying `{threadId, turnId, targetItemId, review, action?}` when guardian approval review resolves. This shape is expected to change soon. +- `item/autoApprovalReview/started` — [UNSTABLE] temporary guardian notification carrying `{threadId, turnId, targetItemId, review, action}` when guardian approval review begins. This shape is expected to change soon. +- `item/autoApprovalReview/completed` — [UNSTABLE] temporary guardian notification carrying `{threadId, turnId, targetItemId, review, action}` when guardian approval review resolves. This shape is expected to change soon. -`review` is [UNSTABLE] and currently has `{status, riskScore?, riskLevel?, rationale?}`, where `status` is one of `inProgress`, `approved`, `denied`, or `aborted`. `action` is the guardian action summary payload from core when available and is intended to support temporary standalone pending-review UI. These notifications are separate from the target item's own `item/completed` lifecycle and are intentionally temporary while the guardian app protocol is still being designed. +`review` is [UNSTABLE] and currently has `{status, riskScore?, riskLevel?, rationale?}`, where `status` is one of `inProgress`, `approved`, `denied`, or `aborted`. `action` is a tagged union with `type: "command" | "execve" | "applyPatch" | "networkAccess" | "mcpToolCall"`. Command-like actions include a `source` discriminator (`"shell"` or `"unifiedExec"`). These notifications are separate from the target item's own `item/completed` lifecycle and are intentionally temporary while the guardian app protocol is still being designed. There are additional item-specific events: @@ -1012,9 +1014,14 @@ Order of messages: `turnId` is best-effort. When the elicitation is correlated with an active turn, the request includes that turn id; otherwise it is `null`. +For MCP tool approval elicitations, form request `meta` includes +`codex_approval_kind: "mcp_tool_call"` and may include `persist: "session"`, +`persist: "always"`, or `persist: ["session", "always"]` to advertise whether +the client can offer session-scoped and/or persistent approval choices. + ### Permission requests -The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the standalone tool's narrower permission shape, so it can request network access and additional filesystem access but does not include the broader `macos` branch used by command-execution `additionalPermissions`. +The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the command-execution `additionalPermissions` shape: it can request network access and additional filesystem access. ```json { @@ -1050,7 +1057,7 @@ The client responds with `result.permissions`, which should be the granted subse } ``` -Only the granted subset matters on the wire. Any permissions omitted from `result.permissions` are treated as denied, including omitted nested keys inside `result.permissions.macos`, so a sparse response like `{ "permissions": { "macos": { "accessibility": true } } }` grants only accessibility. Any permissions not present in the original request are ignored by the server. +Only the granted subset matters on the wire. Any permissions omitted from `result.permissions` are treated as denied. Any permissions not present in the original request are ignored by the server. Within the same turn, granted permissions are sticky: later shell-like tool calls can automatically reuse the granted subset without reissuing a separate permission request. @@ -1304,14 +1311,14 @@ The JSON-RPC auth/account surface exposes request/response methods plus server-i Codex supports these authentication modes. The current mode is surfaced in `account/updated` (`authMode`), which also includes the current ChatGPT `planType` when available, and can be inferred from `account/read`. - **API key (`apiKey`)**: Caller supplies an OpenAI API key via `account/login/start` with `type: "apiKey"`. The API key is saved and used for API requests. -- **ChatGPT managed (`chatgpt`)** (recommended): Codex owns the ChatGPT OAuth flow and refresh tokens. Start via `account/login/start` with `type: "chatgpt"`; Codex persists tokens to disk and refreshes them automatically. +- **ChatGPT managed (`chatgpt`)** (recommended): Codex owns the ChatGPT OAuth flow and refresh tokens. Start via `account/login/start` with `type: "chatgpt"` for the browser flow or `type: "chatgptDeviceCode"` for device code; Codex persists tokens to disk and refreshes them automatically. ### API Overview - `account/read` — fetch current account info; optionally refresh tokens. -- `account/login/start` — begin login (`apiKey`, `chatgpt`). +- `account/login/start` — begin login (`apiKey`, `chatgpt`, `chatgptDeviceCode`). - `account/login/completed` (notify) — emitted when a login attempt finishes (success or error). -- `account/login/cancel` — cancel a pending ChatGPT login by `loginId`. +- `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`. - `account/logout` — sign out; triggers `account/updated`. - `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available. - `account/rateLimits/read` — fetch ChatGPT rate limits; updates arrive via `account/rateLimits/updated` (notify). @@ -1375,26 +1382,40 @@ Field notes: { "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus" } } ``` -### 4) Cancel a ChatGPT login +### 4) Log in with ChatGPT (device code flow) + +1. Start: + ```json + { "method": "account/login/start", "id": 4, "params": { "type": "chatgptDeviceCode" } } + { "id": 4, "result": { "type": "chatgptDeviceCode", "loginId": "", "verificationUrl": "https://auth.openai.com/codex/device", "userCode": "ABCD-1234" } } + ``` +2. Show `verificationUrl` and `userCode` to the user; the frontend owns the UX. +3. Wait for notifications: + ```json + { "method": "account/login/completed", "params": { "loginId": "", "success": true, "error": null } } + { "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus" } } + ``` + +### 5) Cancel a ChatGPT login ```json -{ "method": "account/login/cancel", "id": 4, "params": { "loginId": "" } } +{ "method": "account/login/cancel", "id": 5, "params": { "loginId": "" } } { "method": "account/login/completed", "params": { "loginId": "", "success": false, "error": "…" } } ``` -### 5) Logout +### 6) Logout ```json -{ "method": "account/logout", "id": 5 } -{ "id": 5, "result": {} } +{ "method": "account/logout", "id": 6 } +{ "id": 6, "result": {} } { "method": "account/updated", "params": { "authMode": null, "planType": null } } ``` -### 6) Rate limits (ChatGPT) +### 7) Rate limits (ChatGPT) ```json -{ "method": "account/rateLimits/read", "id": 6 } -{ "id": 6, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null } } } +{ "method": "account/rateLimits/read", "id": 7 } +{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null } } } { "method": "account/rateLimits/updated", "params": { "rateLimits": { … } } } ``` diff --git a/codex-rs/app-server/src/app_server_tracing.rs b/codex-rs/app-server/src/app_server_tracing.rs index 26fe8ca999..b06a8e52c4 100644 --- a/codex-rs/app-server/src/app_server_tracing.rs +++ b/codex-rs/app-server/src/app_server_tracing.rs @@ -86,6 +86,7 @@ fn transport_name(transport: AppServerTransport) -> &'static str { match transport { AppServerTransport::Stdio => "stdio", AppServerTransport::WebSocket { .. } => "websocket", + AppServerTransport::Off => "off", } } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 15484d3d5f..f5c60fe9dc 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -7,9 +7,9 @@ use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::outgoing_message::ClientRequestResult; use crate::outgoing_message::ThreadScopedOutgoingMessageSender; use crate::server_request_error::is_turn_transition_server_request_error; -use crate::thread_state::ThreadListenerCommand; use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; +use crate::thread_state::resolve_server_request_on_thread_listener; use crate::thread_status::ThreadWatchActiveGuard; use crate::thread_status::ThreadWatchManager; use codex_app_server_protocol::AccountRateLimitsUpdatedNotification; @@ -26,7 +26,6 @@ use codex_app_server_protocol::CommandExecutionApprovalDecision; use codex_app_server_protocol::CommandExecutionOutputDeltaNotification; use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; -use codex_app_server_protocol::CommandExecutionRequestApprovalSkillMetadata; use codex_app_server_protocol::CommandExecutionSource; use codex_app_server_protocol::CommandExecutionStatus; use codex_app_server_protocol::ContextCompactedNotification; @@ -44,14 +43,10 @@ use codex_app_server_protocol::FileChangeRequestApprovalParams; use codex_app_server_protocol::FileChangeRequestApprovalResponse; use codex_app_server_protocol::FileUpdateChange; use codex_app_server_protocol::GrantedPermissionProfile as V2GrantedPermissionProfile; -use codex_app_server_protocol::GuardianApprovalReview; -use codex_app_server_protocol::GuardianApprovalReviewStatus; use codex_app_server_protocol::HookCompletedNotification; use codex_app_server_protocol::HookStartedNotification; use codex_app_server_protocol::InterruptConversationResponse; use codex_app_server_protocol::ItemCompletedNotification; -use codex_app_server_protocol::ItemGuardianApprovalReviewCompletedNotification; -use codex_app_server_protocol::ItemGuardianApprovalReviewStartedNotification; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::McpServerElicitationAction; @@ -103,8 +98,14 @@ use codex_app_server_protocol::TurnPlanStep; use codex_app_server_protocol::TurnPlanUpdatedNotification; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::build_command_execution_end_item; +use codex_app_server_protocol::build_file_change_approval_request_item; +use codex_app_server_protocol::build_file_change_begin_item; +use codex_app_server_protocol::build_file_change_end_item; +use codex_app_server_protocol::build_item_from_guardian_event; use codex_app_server_protocol::build_turns_from_rollout_items; use codex_app_server_protocol::convert_patch_changes; +use codex_app_server_protocol::guardian_auto_approval_review_notification; use codex_core::CodexThread; use codex_core::ThreadManager; use codex_core::find_thread_name_by_id; @@ -115,13 +116,10 @@ use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynam use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse; use codex_protocol::items::parse_hook_prompt_message; use codex_protocol::plan_tool::UpdatePlanArgs; -use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecApprovalRequestEvent; -use codex_protocol::protocol::ExecCommandEndEvent; -use codex_protocol::protocol::GuardianAssessmentEvent; use codex_protocol::protocol::McpToolCallBeginEvent; use codex_protocol::protocol::McpToolCallEndEvent; use codex_protocol::protocol::Op; @@ -129,6 +127,8 @@ use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -138,7 +138,6 @@ use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestU use codex_sandboxing::policy_transforms::intersect_permission_profiles; use codex_shell_command::parse_command::shlex_join; use std::collections::HashMap; -use std::convert::TryFrom; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; @@ -154,104 +153,13 @@ enum CommandExecutionApprovalPresentation { Command(CommandExecutionCompletionItem), } +#[derive(Debug, PartialEq)] struct CommandExecutionCompletionItem { command: String, cwd: PathBuf, command_actions: Vec, } -async fn resolve_server_request_on_thread_listener( - thread_state: &Arc>, - request_id: RequestId, -) { - let (completion_tx, completion_rx) = oneshot::channel(); - let listener_command_tx = { - let state = thread_state.lock().await; - state.listener_command_tx() - }; - let Some(listener_command_tx) = listener_command_tx else { - error!("failed to remove pending client request: thread listener is not running"); - return; - }; - - if listener_command_tx - .send(ThreadListenerCommand::ResolveServerRequest { - request_id, - completion_tx, - }) - .is_err() - { - error!( - "failed to remove pending client request: thread listener command channel is closed" - ); - return; - } - - if let Err(err) = completion_rx.await { - error!("failed to remove pending client request: {err}"); - } -} - -fn guardian_auto_approval_review_notification( - conversation_id: &ThreadId, - event_turn_id: &str, - assessment: &GuardianAssessmentEvent, -) -> ServerNotification { - // TODO(ccunningham): Attach guardian review state to the reviewed tool - // item's lifecycle instead of sending standalone review notifications so - // the app-server API can persist and replay review state via `thread/read`. - let turn_id = if assessment.turn_id.is_empty() { - event_turn_id.to_string() - } else { - assessment.turn_id.clone() - }; - let review = GuardianApprovalReview { - status: match assessment.status { - codex_protocol::protocol::GuardianAssessmentStatus::InProgress => { - GuardianApprovalReviewStatus::InProgress - } - codex_protocol::protocol::GuardianAssessmentStatus::Approved => { - GuardianApprovalReviewStatus::Approved - } - codex_protocol::protocol::GuardianAssessmentStatus::Denied => { - GuardianApprovalReviewStatus::Denied - } - codex_protocol::protocol::GuardianAssessmentStatus::Aborted => { - GuardianApprovalReviewStatus::Aborted - } - }, - risk_score: assessment.risk_score, - risk_level: assessment.risk_level.map(Into::into), - rationale: assessment.rationale.clone(), - }; - match assessment.status { - codex_protocol::protocol::GuardianAssessmentStatus::InProgress => { - ServerNotification::ItemGuardianApprovalReviewStarted( - ItemGuardianApprovalReviewStartedNotification { - thread_id: conversation_id.to_string(), - turn_id, - target_item_id: assessment.id.clone(), - review, - action: assessment.action.clone(), - }, - ) - } - codex_protocol::protocol::GuardianAssessmentStatus::Approved - | codex_protocol::protocol::GuardianAssessmentStatus::Denied - | codex_protocol::protocol::GuardianAssessmentStatus::Aborted => { - ServerNotification::ItemGuardianApprovalReviewCompleted( - ItemGuardianApprovalReviewCompletedNotification { - thread_id: conversation_id.to_string(), - turn_id, - target_item_id: assessment.id.clone(), - review, - action: assessment.action.clone(), - }, - ) - } - } -} - #[allow(clippy::too_many_arguments)] pub(crate) async fn apply_bespoke_event_handling( event: Event, @@ -284,6 +192,9 @@ pub(crate) async fn apply_bespoke_event_handling( items: Vec::new(), error: None, status: TurnStatus::InProgress, + started_at: payload.started_at, + completed_at: None, + duration_ms: None, }) }; let notification = TurnStartedNotification { @@ -295,14 +206,21 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::TurnComplete(_ev) => { + EventMsg::TurnComplete(turn_complete_event) => { // All per-thread requests are bound to a turn, so abort them. outgoing.abort_pending_server_requests().await; let turn_failed = thread_state.lock().await.turn_summary.last_error.is_some(); thread_watch_manager .note_turn_completed(&conversation_id.to_string(), turn_failed) .await; - handle_turn_complete(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_complete( + conversation_id, + event_turn_id, + turn_complete_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { @@ -344,12 +262,71 @@ pub(crate) async fn apply_bespoke_event_handling( EventMsg::Warning(_warning_event) => {} EventMsg::GuardianAssessment(assessment) => { if let ApiVersion::V2 = api_version { + let pending_command_execution = match build_item_from_guardian_event( + &assessment, + CommandExecutionStatus::InProgress, + ) { + Some(ThreadItem::CommandExecution { + command, + cwd, + command_actions, + .. + }) => Some(CommandExecutionCompletionItem { + command, + cwd, + command_actions, + }), + Some(_) | None => None, + }; + let assessment_turn_id = if assessment.turn_id.is_empty() { + event_turn_id.clone() + } else { + assessment.turn_id.clone() + }; + if assessment.status + == codex_protocol::protocol::GuardianAssessmentStatus::InProgress + && let Some(completion_item) = pending_command_execution.as_ref() + { + start_command_execution_item( + &conversation_id, + assessment_turn_id.clone(), + assessment.id.clone(), + completion_item.command.clone(), + completion_item.cwd.clone(), + completion_item.command_actions.clone(), + CommandExecutionSource::Agent, + &outgoing, + &thread_state, + ) + .await; + } let notification = guardian_auto_approval_review_notification( &conversation_id, &event_turn_id, &assessment, ); outgoing.send_server_notification(notification).await; + if matches!( + assessment.status, + codex_protocol::protocol::GuardianAssessmentStatus::Denied + | codex_protocol::protocol::GuardianAssessmentStatus::Aborted + ) && let Some(completion_item) = pending_command_execution + { + complete_command_execution_item( + &conversation_id, + assessment_turn_id, + assessment.id.clone(), + completion_item.command, + completion_item.cwd, + /*process_id*/ None, + CommandExecutionSource::Agent, + completion_item.command_actions, + CommandExecutionStatus::Declined, + &outgoing, + &thread_state, + ) + .await; + } } } EventMsg::ModelReroute(event) => { @@ -503,13 +480,7 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } - EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { - call_id, - turn_id, - changes, - reason, - grant_root, - }) => { + EventMsg::ApplyPatchApprovalRequest(event) => { let permission_guard = thread_watch_manager .note_permission_requested(&conversation_id.to_string()) .await; @@ -517,14 +488,15 @@ pub(crate) async fn apply_bespoke_event_handling( ApiVersion::V1 => { let params = ApplyPatchApprovalParams { conversation_id, - call_id: call_id.clone(), - file_changes: changes.clone(), - reason, - grant_root, + call_id: event.call_id.clone(), + file_changes: event.changes.clone(), + reason: event.reason.clone(), + grant_root: event.grant_root.clone(), }; let (_pending_request_id, rx) = outgoing .send_request(ServerRequestPayload::ApplyPatchApproval(params)) .await; + let call_id = event.call_id.clone(); tokio::spawn(async move { let _permission_guard = permission_guard; on_patch_approval_response(call_id, rx, conversation).await; @@ -533,9 +505,8 @@ pub(crate) async fn apply_bespoke_event_handling( ApiVersion::V2 => { // Until we migrate the core to be aware of a first class FileChangeItem // and emit the corresponding EventMsg, we repurpose the call_id as the item_id. - let item_id = call_id.clone(); - let patch_changes = convert_patch_changes(&changes); - + let item_id = event.call_id.clone(); + let patch_changes = convert_patch_changes(&event.changes); let first_start = { let mut state = thread_state.lock().await; state @@ -544,11 +515,7 @@ pub(crate) async fn apply_bespoke_event_handling( .insert(item_id.clone()) }; if first_start { - let item = ThreadItem::FileChange { - id: item_id.clone(), - changes: patch_changes.clone(), - status: PatchApplyStatus::InProgress, - }; + let item = build_file_change_approval_request_item(&event); let notification = ItemStartedNotification { thread_id: conversation_id.to_string(), turn_id: event_turn_id.clone(), @@ -561,10 +528,10 @@ pub(crate) async fn apply_bespoke_event_handling( let params = FileChangeRequestApprovalParams { thread_id: conversation_id.to_string(), - turn_id: turn_id.clone(), + turn_id: event.turn_id.clone(), item_id: item_id.clone(), - reason, - grant_root, + reason: event.reason.clone(), + grant_root: event.grant_root.clone(), }; let (pending_request_id, rx) = outgoing .send_request(ServerRequestPayload::FileChangeRequestApproval(params)) @@ -608,7 +575,6 @@ pub(crate) async fn apply_bespoke_event_handling( proposed_execpolicy_amendment, proposed_network_policy_amendments, additional_permissions, - skill_metadata, parsed_cmd, .. } = ev; @@ -669,6 +635,22 @@ pub(crate) async fn apply_bespoke_event_handling( Some(completion_item), ), }; + if approval_id.is_none() + && let Some(completion_item) = completion_item.as_ref() + { + start_command_execution_item( + &conversation_id, + event_turn_id.clone(), + call_id.clone(), + completion_item.command.clone(), + completion_item.cwd.clone(), + completion_item.command_actions.clone(), + CommandExecutionSource::Agent, + &outgoing, + &thread_state, + ) + .await; + } let proposed_execpolicy_amendment_v2 = proposed_execpolicy_amendment.map(V2ExecPolicyAmendment::from); let proposed_network_policy_amendments_v2 = proposed_network_policy_amendments @@ -680,8 +662,6 @@ pub(crate) async fn apply_bespoke_event_handling( }); let additional_permissions = additional_permissions.map(V2AdditionalPermissionProfile::from); - let skill_metadata = - skill_metadata.map(CommandExecutionRequestApprovalSkillMetadata::from); let params = CommandExecutionRequestApprovalParams { thread_id: conversation_id.to_string(), @@ -694,7 +674,6 @@ pub(crate) async fn apply_bespoke_event_handling( cwd, command_actions, additional_permissions, - skill_metadata, proposed_execpolicy_amendment: proposed_execpolicy_amendment_v2, proposed_network_policy_amendments: proposed_network_policy_amendments_v2, available_decisions: Some(available_decisions), @@ -1559,7 +1538,6 @@ pub(crate) async fn apply_bespoke_event_handling( // Until we migrate the core to be aware of a first class FileChangeItem // and emit the corresponding EventMsg, we repurpose the call_id as the item_id. let item_id = patch_begin_event.call_id.clone(); - let changes = convert_patch_changes(&patch_begin_event.changes); let first_start = { let mut state = thread_state.lock().await; @@ -1569,11 +1547,7 @@ pub(crate) async fn apply_bespoke_event_handling( .insert(item_id.clone()) }; if first_start { - let item = ThreadItem::FileChange { - id: item_id.clone(), - changes, - status: PatchApplyStatus::InProgress, - }; + let item = build_file_change_begin_item(&patch_begin_event); let notification = ItemStartedNotification { thread_id: conversation_id.to_string(), turn_id: event_turn_id.clone(), @@ -1588,14 +1562,10 @@ pub(crate) async fn apply_bespoke_event_handling( // Until we migrate the core to be aware of a first class FileChangeItem // and emit the corresponding EventMsg, we repurpose the call_id as the item_id. let item_id = patch_end_event.call_id.clone(); - - let status: PatchApplyStatus = (&patch_end_event.status).into(); - let changes = convert_patch_changes(&patch_end_event.changes); complete_file_change_item( conversation_id, item_id, - changes, - status, + build_file_change_end_item(&patch_end_event), event_turn_id.clone(), &outgoing, &thread_state, @@ -1612,35 +1582,35 @@ pub(crate) async fn apply_bespoke_event_handling( let command = shlex_join(&exec_command_begin_event.command); let cwd = exec_command_begin_event.cwd; let process_id = exec_command_begin_event.process_id; - - { + let first_start = { let mut state = thread_state.lock().await; state .turn_summary .command_execution_started - .insert(item_id.clone()); + .insert(item_id.clone()) + }; + if first_start { + let item = ThreadItem::CommandExecution { + id: item_id, + command, + cwd, + process_id, + source: exec_command_begin_event.source.into(), + status: CommandExecutionStatus::InProgress, + command_actions, + aggregated_output: None, + exit_code: None, + duration_ms: None, + }; + let notification = ItemStartedNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + item, + }; + outgoing + .send_server_notification(ServerNotification::ItemStarted(notification)) + .await; } - - let item = ThreadItem::CommandExecution { - id: item_id, - command, - cwd, - process_id, - source: exec_command_begin_event.source.into(), - status: CommandExecutionStatus::InProgress, - command_actions, - aggregated_output: None, - exit_code: None, - duration_ms: None, - }; - let notification = ItemStartedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item, - }; - outgoing - .send_server_notification(ServerNotification::ItemStarted(notification)) - .await; } EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event) => { let item_id = exec_command_output_delta_event.call_id.clone(); @@ -1698,20 +1668,7 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::ExecCommandEnd(exec_command_end_event) => { - let ExecCommandEndEvent { - call_id, - command, - cwd, - parsed_cmd, - process_id, - aggregated_output, - exit_code, - duration, - source, - status, - .. - } = exec_command_end_event; - + let call_id = exec_command_end_event.call_id.clone(); { let mut state = thread_state.lock().await; state @@ -1720,32 +1677,7 @@ pub(crate) async fn apply_bespoke_event_handling( .remove(&call_id); } - let status: CommandExecutionStatus = (&status).into(); - let command_actions = parsed_cmd - .into_iter() - .map(V2ParsedCommand::from) - .collect::>(); - - let aggregated_output = if aggregated_output.is_empty() { - None - } else { - Some(aggregated_output) - }; - - let duration_ms = i64::try_from(duration.as_millis()).unwrap_or(i64::MAX); - - let item = ThreadItem::CommandExecution { - id: call_id, - command: shlex_join(&command), - cwd, - process_id, - source: source.into(), - status, - command_actions, - aggregated_output, - exit_code: Some(exit_code), - duration_ms: Some(duration_ms), - }; + let item = build_command_execution_end_item(&exec_command_end_event); let notification = ItemCompletedNotification { thread_id: conversation_id.to_string(), @@ -1784,7 +1716,14 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager .note_turn_interrupted(&conversation_id.to_string()) .await; - handle_turn_interrupted(conversation_id, event_turn_id, &outgoing, &thread_state).await; + handle_turn_interrupted( + conversation_id, + event_turn_id, + turn_aborted_event, + &outgoing, + &thread_state, + ) + .await; } EventMsg::ThreadRolledBack(_rollback_event) => { let pending = { @@ -1946,11 +1885,18 @@ async fn handle_turn_plan_update( } } +struct TurnCompletionMetadata { + status: TurnStatus, + error: Option, + started_at: Option, + completed_at: Option, + duration_ms: Option, +} + async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, - status: TurnStatus, - error: Option, + turn_completion_metadata: TurnCompletionMetadata, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1958,8 +1904,11 @@ async fn emit_turn_completed_with_status( turn: Turn { id: event_turn_id, items: vec![], - error, - status, + error: turn_completion_metadata.error, + status: turn_completion_metadata.status, + started_at: turn_completion_metadata.started_at, + completed_at: turn_completion_metadata.completed_at, + duration_ms: turn_completion_metadata.duration_ms, }, }; outgoing @@ -1970,8 +1919,7 @@ async fn emit_turn_completed_with_status( async fn complete_file_change_item( conversation_id: ThreadId, item_id: String, - changes: Vec, - status: PatchApplyStatus, + item: ThreadItem, turn_id: String, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, @@ -1980,11 +1928,6 @@ async fn complete_file_change_item( state.turn_summary.file_change_started.remove(&item_id); drop(state); - let item = ThreadItem::FileChange { - id: item_id, - changes, - status, - }; let notification = ItemCompletedNotification { thread_id: conversation_id.to_string(), turn_id, @@ -1995,9 +1938,52 @@ async fn complete_file_change_item( .await; } +#[allow(clippy::too_many_arguments)] +async fn start_command_execution_item( + conversation_id: &ThreadId, + turn_id: String, + item_id: String, + command: String, + cwd: PathBuf, + command_actions: Vec, + source: CommandExecutionSource, + outgoing: &ThreadScopedOutgoingMessageSender, + thread_state: &Arc>, +) -> bool { + let first_start = { + let mut state = thread_state.lock().await; + state + .turn_summary + .command_execution_started + .insert(item_id.clone()) + }; + if first_start { + let notification = ItemStartedNotification { + thread_id: conversation_id.to_string(), + turn_id, + item: ThreadItem::CommandExecution { + id: item_id, + command, + cwd, + process_id: None, + source, + status: CommandExecutionStatus::InProgress, + command_actions, + aggregated_output: None, + exit_code: None, + duration_ms: None, + }, + }; + outgoing + .send_server_notification(ServerNotification::ItemStarted(notification)) + .await; + } + first_start +} + #[allow(clippy::too_many_arguments)] async fn complete_command_execution_item( - conversation_id: ThreadId, + conversation_id: &ThreadId, turn_id: String, item_id: String, command: String, @@ -2007,7 +1993,18 @@ async fn complete_command_execution_item( command_actions: Vec, status: CommandExecutionStatus, outgoing: &ThreadScopedOutgoingMessageSender, + thread_state: &Arc>, ) { + let mut state = thread_state.lock().await; + let should_emit = state + .turn_summary + .command_execution_started + .remove(&item_id); + drop(state); + if !should_emit { + return; + } + let item = ThreadItem::CommandExecution { id: item_id, command, @@ -2105,6 +2102,7 @@ async fn find_and_remove_turn_summary( async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, + turn_complete_event: TurnCompleteEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -2115,22 +2113,40 @@ async fn handle_turn_complete( None => (TurnStatus::Completed, None), }; - emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; + emit_turn_completed_with_status( + conversation_id, + event_turn_id, + TurnCompletionMetadata { + status, + error, + started_at: turn_summary.started_at, + completed_at: turn_complete_event.completed_at, + duration_ms: turn_complete_event.duration_ms, + }, + outgoing, + ) + .await; } async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, + turn_aborted_event: TurnAbortedEvent, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - find_and_remove_turn_summary(conversation_id, thread_state).await; + let turn_summary = find_and_remove_turn_summary(conversation_id, thread_state).await; emit_turn_completed_with_status( conversation_id, event_turn_id, - TurnStatus::Interrupted, - /*error*/ None, + TurnCompletionMetadata { + status: TurnStatus::Interrupted, + error: None, + started_at: turn_summary.started_at, + completed_at: turn_aborted_event.completed_at, + duration_ms: turn_aborted_event.duration_ms, + }, outgoing, ) .await; @@ -2596,8 +2612,11 @@ async fn on_file_change_request_approval_response( complete_file_change_item( conversation_id, item_id.clone(), - changes, - status, + ThreadItem::FileChange { + id: item_id.clone(), + changes, + status, + }, event_turn_id.clone(), &outgoing, &thread_state, @@ -2714,7 +2733,7 @@ async fn on_command_execution_request_approval_response( && let Some(completion_item) = completion_item { complete_command_execution_item( - conversation_id, + &conversation_id, event_turn_id.clone(), item_id.clone(), completion_item.command, @@ -2724,6 +2743,7 @@ async fn on_command_execution_request_approval_response( completion_item.command_actions, status, &outgoing, + &thread_state, ) .await; } @@ -2823,6 +2843,7 @@ async fn construct_mcp_tool_call_end_notification( Some(McpToolCallResult { content: value.content.clone(), structured_content: value.structured_content.clone(), + meta: value.meta.clone(), }), None, ), @@ -2865,6 +2886,7 @@ mod tests { use codex_app_server_protocol::GuardianApprovalReviewStatus; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::TurnPlanStepStatus; + use codex_login::CodexAuth; use codex_protocol::items::HookPromptFragment; use codex_protocol::items::build_hook_prompt_message; use codex_protocol::mcp::CallToolResult; @@ -2875,17 +2897,22 @@ mod tests { use codex_protocol::protocol::CollabResumeBeginEvent; use codex_protocol::protocol::CollabResumeEndEvent; use codex_protocol::protocol::CreditsSnapshot; + use codex_protocol::protocol::GuardianAssessmentEvent; + use codex_protocol::protocol::GuardianAssessmentStatus; use codex_protocol::protocol::McpInvocation; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; use codex_utils_absolute_path::AbsolutePathBuf; + use core_test_support::load_default_config_for_test; use pretty_assertions::assert_eq; use rmcp::model::Content; use serde_json::Value as JsonValue; use serde_json::json; + use std::path::PathBuf; use std::time::Duration; + use tempfile::TempDir; use tokio::sync::Mutex; use tokio::sync::mpsc; @@ -2893,6 +2920,9 @@ mod tests { Arc::new(Mutex::new(ThreadState::default())) } + const TEST_TURN_COMPLETED_AT: i64 = 1_716_000_456; + const TEST_TURN_DURATION_MS: i64 = 1_234; + async fn recv_broadcast_message( rx: &mut mpsc::Receiver, ) -> Result { @@ -2906,13 +2936,110 @@ mod tests { } } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { + TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: Some(TEST_TURN_COMPLETED_AT), + duration_ms: Some(TEST_TURN_DURATION_MS), + } + } + + fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { + TurnAbortedEvent { + turn_id: Some(turn_id.to_string()), + reason: codex_protocol::protocol::TurnAbortReason::Interrupted, + completed_at: Some(TEST_TURN_COMPLETED_AT), + duration_ms: Some(TEST_TURN_DURATION_MS), + } + } + + fn command_execution_completion_item(command: &str) -> CommandExecutionCompletionItem { + CommandExecutionCompletionItem { + command: command.to_string(), + cwd: PathBuf::from("/tmp"), + command_actions: vec![V2ParsedCommand::Unknown { + command: command.to_string(), + }], + } + } + + fn guardian_command_assessment( + id: &str, + turn_id: &str, + status: GuardianAssessmentStatus, + ) -> GuardianAssessmentEvent { + let (risk_score, risk_level, rationale) = match status { + GuardianAssessmentStatus::InProgress => (None, None, None), + GuardianAssessmentStatus::Approved => ( + Some(12), + Some(codex_protocol::protocol::GuardianRiskLevel::Low), + Some("looks safe".to_string()), + ), + GuardianAssessmentStatus::Denied => ( + Some(88), + Some(codex_protocol::protocol::GuardianRiskLevel::High), + Some("too risky".to_string()), + ), + GuardianAssessmentStatus::Aborted => (None, None, None), + }; + GuardianAssessmentEvent { + id: id.to_string(), + turn_id: turn_id.to_string(), + status, + risk_score, + risk_level, + rationale, + action: serde_json::from_value(json!({ + "type": "command", + "source": "shell", + "command": format!("rm -f /tmp/{id}.sqlite"), + "cwd": "/tmp", + })) + .expect("guardian action"), + } + } + + struct GuardianAssessmentTestContext { + conversation_id: ThreadId, + conversation: Arc, + thread_manager: Arc, + outgoing: ThreadScopedOutgoingMessageSender, + thread_state: Arc>, + thread_watch_manager: ThreadWatchManager, + codex_home: PathBuf, + } + + impl GuardianAssessmentTestContext { + async fn apply_guardian_assessment_event(&self, assessment: GuardianAssessmentEvent) { + let event_turn_id = assessment.turn_id.clone(); + apply_bespoke_event_handling( + Event { + id: event_turn_id, + msg: EventMsg::GuardianAssessment(assessment), + }, + self.conversation_id, + self.conversation.clone(), + self.thread_manager.clone(), + self.outgoing.clone(), + self.thread_state.clone(), + self.thread_watch_manager.clone(), + ApiVersion::V2, + "test-provider".to_string(), + &self.codex_home, + ) + .await; + } + } + #[test] fn guardian_assessment_started_uses_event_turn_id_fallback() { let conversation_id = ThreadId::new(); - let action = json!({ - "tool": "shell", - "command": "rm -rf /tmp/example.sqlite", - }); + let action = codex_protocol::protocol::GuardianAssessmentAction::Command { + source: codex_protocol::protocol::GuardianCommandSource::Shell, + command: "rm -rf /tmp/example.sqlite".to_string(), + cwd: "/tmp".into(), + }; let notification = guardian_auto_approval_review_notification( &conversation_id, "turn-from-event", @@ -2923,7 +3050,7 @@ mod tests { risk_score: None, risk_level: None, rationale: None, - action: Some(action.clone()), + action: action.clone(), }, ); @@ -2939,7 +3066,7 @@ mod tests { assert_eq!(payload.review.risk_score, None); assert_eq!(payload.review.risk_level, None); assert_eq!(payload.review.rationale, None); - assert_eq!(payload.action, Some(action)); + assert_eq!(payload.action, action.into()); } other => panic!("unexpected notification: {other:?}"), } @@ -2948,10 +3075,11 @@ mod tests { #[test] fn guardian_assessment_completed_emits_review_payload() { let conversation_id = ThreadId::new(); - let action = json!({ - "tool": "shell", - "command": "rm -rf /tmp/example.sqlite", - }); + let action = codex_protocol::protocol::GuardianAssessmentAction::Command { + source: codex_protocol::protocol::GuardianCommandSource::Shell, + command: "rm -rf /tmp/example.sqlite".to_string(), + cwd: "/tmp".into(), + }; let notification = guardian_auto_approval_review_notification( &conversation_id, "turn-from-event", @@ -2962,7 +3090,7 @@ mod tests { risk_score: Some(91), risk_level: Some(codex_protocol::protocol::GuardianRiskLevel::High), rationale: Some("too risky".to_string()), - action: Some(action.clone()), + action: action.clone(), }, ); @@ -2978,7 +3106,7 @@ mod tests { Some(codex_app_server_protocol::GuardianRiskLevel::High) ); assert_eq!(payload.review.rationale.as_deref(), Some("too risky")); - assert_eq!(payload.action, Some(action)); + assert_eq!(payload.action, action.into()); } other => panic!("unexpected notification: {other:?}"), } @@ -2987,10 +3115,12 @@ mod tests { #[test] fn guardian_assessment_aborted_emits_completed_review_payload() { let conversation_id = ThreadId::new(); - let action = json!({ - "tool": "network_access", - "target": "api.openai.com:443", - }); + let action = codex_protocol::protocol::GuardianAssessmentAction::NetworkAccess { + target: "api.openai.com:443".to_string(), + host: "api.openai.com".to_string(), + protocol: codex_protocol::protocol::NetworkApprovalProtocol::Https, + port: 443, + }; let notification = guardian_auto_approval_review_notification( &conversation_id, "turn-from-event", @@ -3001,7 +3131,7 @@ mod tests { risk_score: None, risk_level: None, rationale: None, - action: Some(action.clone()), + action: action.clone(), }, ); @@ -3014,12 +3144,319 @@ mod tests { assert_eq!(payload.review.risk_score, None); assert_eq!(payload.review.risk_level, None); assert_eq!(payload.review.rationale, None); - assert_eq!(payload.action, Some(action)); + assert_eq!(payload.action, action.into()); } other => panic!("unexpected notification: {other:?}"), } } + #[tokio::test] + async fn command_execution_started_helper_emits_once() -> Result<()> { + let conversation_id = ThreadId::new(); + let thread_state = new_thread_state(); + let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); + let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing, + vec![ConnectionId(1)], + ThreadId::new(), + ); + let completion_item = command_execution_completion_item("printf hi"); + + let first_start = start_command_execution_item( + &conversation_id, + "turn-1".to_string(), + "cmd-1".to_string(), + completion_item.command.clone(), + completion_item.cwd.clone(), + completion_item.command_actions.clone(), + CommandExecutionSource::Agent, + &outgoing, + &thread_state, + ) + .await; + assert!(first_start); + + let msg = recv_broadcast_message(&mut rx).await?; + match msg { + OutgoingMessage::AppServerNotification(ServerNotification::ItemStarted(payload)) => { + assert_eq!(payload.thread_id, conversation_id.to_string()); + assert_eq!(payload.turn_id, "turn-1"); + assert_eq!( + payload.item, + ThreadItem::CommandExecution { + id: "cmd-1".to_string(), + command: completion_item.command.clone(), + cwd: completion_item.cwd.clone(), + process_id: None, + source: CommandExecutionSource::Agent, + status: CommandExecutionStatus::InProgress, + command_actions: completion_item.command_actions.clone(), + aggregated_output: None, + exit_code: None, + duration_ms: None, + } + ); + } + other => bail!("unexpected message: {other:?}"), + } + + let second_start = start_command_execution_item( + &conversation_id, + "turn-1".to_string(), + "cmd-1".to_string(), + completion_item.command.clone(), + completion_item.cwd.clone(), + completion_item.command_actions.clone(), + CommandExecutionSource::Agent, + &outgoing, + &thread_state, + ) + .await; + assert!(!second_start); + assert!(rx.try_recv().is_err(), "duplicate start should not emit"); + Ok(()) + } + + #[tokio::test] + async fn complete_command_execution_item_emits_declined_once_for_pending_command() -> Result<()> + { + let conversation_id = ThreadId::new(); + let thread_state = new_thread_state(); + let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); + let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing, + vec![ConnectionId(1)], + ThreadId::new(), + ); + let completion_item = command_execution_completion_item("printf hi"); + + start_command_execution_item( + &conversation_id, + "turn-1".to_string(), + "cmd-1".to_string(), + completion_item.command.clone(), + completion_item.cwd.clone(), + completion_item.command_actions.clone(), + CommandExecutionSource::Agent, + &outgoing, + &thread_state, + ) + .await; + let _started = recv_broadcast_message(&mut rx).await?; + + complete_command_execution_item( + &conversation_id, + "turn-1".to_string(), + "cmd-1".to_string(), + completion_item.command.clone(), + completion_item.cwd.clone(), + /*process_id*/ None, + CommandExecutionSource::Agent, + completion_item.command_actions.clone(), + CommandExecutionStatus::Declined, + &outgoing, + &thread_state, + ) + .await; + + let completed = recv_broadcast_message(&mut rx).await?; + match completed { + OutgoingMessage::AppServerNotification(ServerNotification::ItemCompleted(payload)) => { + let ThreadItem::CommandExecution { id, status, .. } = payload.item else { + bail!("expected command execution completion"); + }; + assert_eq!(id, "cmd-1"); + assert_eq!(status, CommandExecutionStatus::Declined); + } + other => bail!("unexpected message: {other:?}"), + } + + complete_command_execution_item( + &conversation_id, + "turn-1".to_string(), + "cmd-1".to_string(), + completion_item.command, + completion_item.cwd, + /*process_id*/ None, + CommandExecutionSource::Agent, + completion_item.command_actions, + CommandExecutionStatus::Declined, + &outgoing, + &thread_state, + ) + .await; + assert!( + rx.try_recv().is_err(), + "completion should not emit after the pending item is cleared" + ); + Ok(()) + } + + #[tokio::test] + async fn guardian_command_execution_notifications_wrap_review_lifecycle() -> Result<()> { + let codex_home = TempDir::new()?; + let config = load_default_config_for_test(&codex_home).await; + let thread_manager = Arc::new( + codex_core::test_support::thread_manager_with_models_provider_and_home( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + config.model_provider.clone(), + config.codex_home.clone(), + Arc::new(codex_exec_server::EnvironmentManager::new( + /*exec_server_url*/ None, + )), + ), + ); + let codex_core::NewThread { + thread_id: conversation_id, + thread: conversation, + .. + } = thread_manager.start_thread(config).await?; + let thread_state = new_thread_state(); + let thread_watch_manager = ThreadWatchManager::new(); + let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); + let outgoing = Arc::new(OutgoingMessageSender::new(tx)); + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing, + vec![ConnectionId(1)], + conversation_id, + ); + let guardian_context = GuardianAssessmentTestContext { + conversation_id, + conversation: conversation.clone(), + thread_manager: thread_manager.clone(), + outgoing: outgoing.clone(), + thread_state: thread_state.clone(), + thread_watch_manager: thread_watch_manager.clone(), + codex_home: codex_home.path().to_path_buf(), + }; + + guardian_context + .apply_guardian_assessment_event(guardian_command_assessment( + "cmd-guardian-approved", + "turn-guardian-approved", + GuardianAssessmentStatus::InProgress, + )) + .await; + let first = recv_broadcast_message(&mut rx).await?; + match first { + OutgoingMessage::AppServerNotification(ServerNotification::ItemStarted(payload)) => { + assert_eq!(payload.turn_id, "turn-guardian-approved"); + let ThreadItem::CommandExecution { id, status, .. } = payload.item else { + bail!("expected command execution item"); + }; + assert_eq!(id, "cmd-guardian-approved"); + assert_eq!(status, CommandExecutionStatus::InProgress); + } + other => bail!("unexpected message: {other:?}"), + } + let second = recv_broadcast_message(&mut rx).await?; + match second { + OutgoingMessage::AppServerNotification( + ServerNotification::ItemGuardianApprovalReviewStarted(payload), + ) => { + assert_eq!(payload.target_item_id, "cmd-guardian-approved"); + assert_eq!( + payload.review.status, + GuardianApprovalReviewStatus::InProgress + ); + } + other => bail!("unexpected message: {other:?}"), + } + + guardian_context + .apply_guardian_assessment_event(guardian_command_assessment( + "cmd-guardian-approved", + "turn-guardian-approved", + GuardianAssessmentStatus::Approved, + )) + .await; + let third = recv_broadcast_message(&mut rx).await?; + match third { + OutgoingMessage::AppServerNotification( + ServerNotification::ItemGuardianApprovalReviewCompleted(payload), + ) => { + assert_eq!(payload.target_item_id, "cmd-guardian-approved"); + assert_eq!( + payload.review.status, + GuardianApprovalReviewStatus::Approved + ); + } + other => bail!("unexpected message: {other:?}"), + } + assert!( + rx.try_recv().is_err(), + "approved review should not complete the command item" + ); + + guardian_context + .apply_guardian_assessment_event(guardian_command_assessment( + "cmd-guardian-denied", + "turn-guardian-denied", + GuardianAssessmentStatus::InProgress, + )) + .await; + let fourth = recv_broadcast_message(&mut rx).await?; + match fourth { + OutgoingMessage::AppServerNotification(ServerNotification::ItemStarted(payload)) => { + assert_eq!(payload.turn_id, "turn-guardian-denied"); + let ThreadItem::CommandExecution { id, status, .. } = payload.item else { + bail!("expected command execution item"); + }; + assert_eq!(id, "cmd-guardian-denied"); + assert_eq!(status, CommandExecutionStatus::InProgress); + } + other => bail!("unexpected message: {other:?}"), + } + let fifth = recv_broadcast_message(&mut rx).await?; + match fifth { + OutgoingMessage::AppServerNotification( + ServerNotification::ItemGuardianApprovalReviewStarted(payload), + ) => { + assert_eq!(payload.target_item_id, "cmd-guardian-denied"); + assert_eq!( + payload.review.status, + GuardianApprovalReviewStatus::InProgress + ); + } + other => bail!("unexpected message: {other:?}"), + } + + guardian_context + .apply_guardian_assessment_event(guardian_command_assessment( + "cmd-guardian-denied", + "turn-guardian-denied", + GuardianAssessmentStatus::Denied, + )) + .await; + let sixth = recv_broadcast_message(&mut rx).await?; + match sixth { + OutgoingMessage::AppServerNotification( + ServerNotification::ItemGuardianApprovalReviewCompleted(payload), + ) => { + assert_eq!(payload.target_item_id, "cmd-guardian-denied"); + assert_eq!(payload.review.status, GuardianApprovalReviewStatus::Denied); + } + other => bail!("unexpected message: {other:?}"), + } + let seventh = recv_broadcast_message(&mut rx).await?; + match seventh { + OutgoingMessage::AppServerNotification(ServerNotification::ItemCompleted(payload)) => { + let ThreadItem::CommandExecution { id, status, .. } = payload.item else { + bail!("expected command execution completion"); + }; + assert_eq!(id, "cmd-guardian-denied"); + assert_eq!(status, CommandExecutionStatus::Declined); + } + other => bail!("unexpected message: {other:?}"), + } + + assert!(rx.try_recv().is_err(), "no extra messages expected"); + conversation.shutdown_and_wait().await?; + Ok(()) + } + #[test] fn file_change_accept_for_session_maps_to_approved_for_session() { let (decision, completion_status) = @@ -3281,10 +3718,25 @@ mod tests { ThreadId::new(), ); let thread_state = new_thread_state(); + { + let mut state = thread_state.lock().await; + state.track_current_turn_event(&EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: event_turn_id.clone(), + started_at: Some(42), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }, + )); + state.track_current_turn_event(&EventMsg::TurnComplete(turn_complete_event( + &event_turn_id, + ))); + } handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3296,6 +3748,9 @@ mod tests { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Completed); assert_eq!(n.turn.error, None); + assert_eq!(n.turn.started_at, Some(42)); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -3329,6 +3784,7 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), + turn_aborted_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3340,6 +3796,8 @@ mod tests { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Interrupted); assert_eq!(n.turn.error, None); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -3373,6 +3831,7 @@ mod tests { handle_turn_complete( conversation_id, event_turn_id.clone(), + turn_complete_event(&event_turn_id), &outgoing, &thread_state, ) @@ -3391,6 +3850,8 @@ mod tests { additional_details: None, }) ); + assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); + assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); } other => bail!("unexpected message: {other:?}"), } @@ -3633,7 +4094,14 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_a, a_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn1.clone(), + turn_complete_event(&a_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 1 on conversation B let b_turn1 = "b_turn1".to_string(); @@ -3647,11 +4115,25 @@ mod tests { &thread_state, ) .await; - handle_turn_complete(conversation_b, b_turn1.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_b, + b_turn1.clone(), + turn_complete_event(&b_turn1), + &outgoing, + &thread_state, + ) + .await; // Turn 2 on conversation A let a_turn2 = "a_turn2".to_string(); - handle_turn_complete(conversation_a, a_turn2.clone(), &outgoing, &thread_state).await; + handle_turn_complete( + conversation_a, + a_turn2.clone(), + turn_complete_event(&a_turn2), + &outgoing, + &thread_state, + ) + .await; // Verify: A turn 1 let msg = recv_broadcast_message(&mut rx).await?; @@ -3752,7 +4234,9 @@ mod tests { content: content.clone(), is_error: Some(false), structured_content: None, - meta: None, + meta: Some(serde_json::json!({ + "ui/resourceUri": "ui://widget/list-resources.html" + })), }; let end_event = McpToolCallEndEvent { @@ -3787,6 +4271,9 @@ mod tests { result: Some(McpToolCallResult { content, structured_content: None, + meta: Some(serde_json::json!({ + "ui/resourceUri": "ui://widget/list-resources.html" + })), }), error: None, duration_ms: Some(0), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 22122a7991..b4fe6523ff 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -20,6 +20,7 @@ use crate::thread_status::resolve_thread_status; use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; @@ -28,10 +29,12 @@ use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::AuthMode as CoreAuthMode; use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; @@ -73,11 +76,14 @@ use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::LoginApiKeyParams; use codex_app_server_protocol::LogoutAccountResponse; use codex_app_server_protocol::MarketplaceInterface; +use codex_app_server_protocol::McpResourceReadParams; +use codex_app_server_protocol::McpResourceReadResponse; use codex_app_server_protocol::McpServerOauthLoginCompletedNotification; use codex_app_server_protocol::McpServerOauthLoginParams; use codex_app_server_protocol::McpServerOauthLoginResponse; use codex_app_server_protocol::McpServerRefreshResponse; use codex_app_server_protocol::McpServerStatus; +use codex_app_server_protocol::McpServerStatusDetail; use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::MockExperimentalMethodResponse; use codex_app_server_protocol::ModelListParams; @@ -179,8 +185,7 @@ use codex_arg0::Arg0DispatchPaths; use codex_backend_client::Client as BackendClient; use codex_chatgpt::connectors; use codex_cloud_requirements::cloud_requirements_loader; -use codex_core::AuthManager; -use codex_core::CodexAuth; +use codex_config::types::McpServerTransportConfig; use codex_core::CodexThread; use codex_core::Cursor as RolloutCursor; use codex_core::ForkSnapshot; @@ -191,23 +196,16 @@ use codex_core::SteerInputError; use codex_core::ThreadConfigSnapshot; use codex_core::ThreadManager; use codex_core::ThreadSortKey as CoreThreadSortKey; -use codex_core::auth::AuthMode as CoreAuthMode; -use codex_core::auth::CLIENT_ID; -use codex_core::auth::login_with_api_key; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::NetworkProxyAuditMetadata; use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; -use codex_core::config::types::McpServerTransportConfig; use codex_core::config_loader::CloudRequirementsLoadError; use codex_core::config_loader::CloudRequirementsLoadErrorCode; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::LoaderOverrides; use codex_core::config_loader::load_config_layers_state; -use codex_core::default_client::set_default_client_residency_requirement; -use codex_core::error::CodexErr; -use codex_core::error::Result as CodexResult; use codex_core::exec::ExecCapturePolicy; use codex_core::exec::ExecExpiration; use codex_core::exec::ExecParams; @@ -216,11 +214,6 @@ use codex_core::find_archived_thread_path_by_id_str; use codex_core::find_thread_name_by_id; use codex_core::find_thread_names_by_ids; use codex_core::find_thread_path_by_id_str; -use codex_core::mcp::auth::discover_supported_scopes; -use codex_core::mcp::auth::resolve_oauth_scopes; -use codex_core::mcp::collect_mcp_snapshot; -use codex_core::mcp::group_tools_by_server; -use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_core::parse_cursor; use codex_core::plugins::MarketplaceError; use codex_core::plugins::MarketplacePluginSource; @@ -235,9 +228,6 @@ use codex_core::read_head_for_summary; use codex_core::read_session_meta_line; use codex_core::rollout_date_parts; use codex_core::sandboxing::SandboxPermissions; -use codex_core::state_db::StateDbHandle; -use codex_core::state_db::get_state_db; -use codex_core::state_db::reconcile_rollout; use codex_core::windows_sandbox::WindowsSandboxLevelExt; use codex_core::windows_sandbox::WindowsSandboxSetupMode as CoreWindowsSandboxSetupMode; use codex_core::windows_sandbox::WindowsSandboxSetupRequest; @@ -246,16 +236,34 @@ use codex_features::Feature; use codex_features::Stage; use codex_feedback::CodexFeedback; use codex_git_utils::git_diff_to_remote; +use codex_git_utils::resolve_root_git_project_for_trust; +use codex_login::AuthManager; +use codex_login::CLIENT_ID; +use codex_login::CodexAuth; use codex_login::ServerOptions as LoginServerOptions; use codex_login::ShutdownHandle; use codex_login::auth::login_with_chatgpt_auth_tokens; +use codex_login::complete_device_code_login; +use codex_login::default_client::set_default_client_residency_requirement; +use codex_login::login_with_api_key; +use codex_login::request_device_code; use codex_login::run_login_server; +use codex_mcp::McpSnapshotDetail; +use codex_mcp::collect_mcp_snapshot_with_detail; +use codex_mcp::discover_supported_scopes; +use codex_mcp::effective_mcp_servers; +use codex_mcp::qualified_mcp_tool_name_prefix; +use codex_mcp::resolve_oauth_scopes; +use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; +use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::dynamic_tools::DynamicToolSpec as CoreDynamicToolSpec; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result as CodexResult; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AgentStatus; @@ -280,6 +288,9 @@ use codex_protocol::protocol::W3cTraceContext; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use codex_protocol::user_input::UserInput as CoreInputItem; use codex_rmcp_client::perform_oauth_login_return_url; +use codex_rollout::state_db::StateDbHandle; +use codex_rollout::state_db::get_state_db; +use codex_rollout::state_db::reconcile_rollout; use codex_state::StateRuntime; use codex_state::ThreadMetadata; use codex_state::ThreadMetadataBuilder; @@ -339,12 +350,39 @@ struct ThreadListFilters { search_term: Option, } -// Duration before a ChatGPT login attempt is abandoned. +// Duration before a browser ChatGPT login attempt is abandoned. const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60); +const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER"; const APP_LIST_LOAD_TIMEOUT: Duration = Duration::from_secs(90); -struct ActiveLogin { - shutdown_handle: ShutdownHandle, - login_id: Uuid, + +enum ActiveLogin { + Browser { + shutdown_handle: ShutdownHandle, + login_id: Uuid, + }, + DeviceCode { + cancel: CancellationToken, + login_id: Uuid, + }, +} + +impl ActiveLogin { + fn login_id(&self) -> Uuid { + match self { + ActiveLogin::Browser { login_id, .. } | ActiveLogin::DeviceCode { login_id, .. } => { + *login_id + } + } + } + + fn cancel(&self) { + match self { + ActiveLogin::Browser { + shutdown_handle, .. + } => shutdown_handle.shutdown(), + ActiveLogin::DeviceCode { cancel, .. } => cancel.cancel(), + } + } } #[derive(Clone, Copy, Debug)] @@ -365,7 +403,7 @@ enum ThreadShutdownResult { impl Drop for ActiveLogin { fn drop(&mut self) { - self.shutdown_handle.shutdown(); + self.cancel(); } } @@ -374,6 +412,7 @@ pub(crate) struct CodexMessageProcessor { auth_manager: Arc, thread_manager: Arc, outgoing: Arc, + analytics_events_client: AnalyticsEventsClient, arg0_paths: Arg0DispatchPaths, config: Arc, cli_overrides: Arc>>, @@ -404,6 +443,8 @@ struct ListenerTaskContext { thread_manager: Arc, thread_state_manager: ThreadStateManager, outgoing: Arc, + analytics_events_client: AnalyticsEventsClient, + general_analytics_enabled: bool, thread_watch_manager: ThreadWatchManager, fallback_model_provider: String, codex_home: PathBuf, @@ -426,6 +467,7 @@ pub(crate) struct CodexMessageProcessorArgs { pub(crate) auth_manager: Arc, pub(crate) thread_manager: Arc, pub(crate) outgoing: Arc, + pub(crate) analytics_events_client: AnalyticsEventsClient, pub(crate) arg0_paths: Arg0DispatchPaths, pub(crate) config: Arc, pub(crate) cli_overrides: Arc>>, @@ -436,22 +478,13 @@ pub(crate) struct CodexMessageProcessorArgs { } impl CodexMessageProcessor { - pub(crate) fn clear_plugin_related_caches(&self) { - self.thread_manager.plugins_manager().clear_cache(); - self.thread_manager.skills_manager().clear_cache(); + pub(crate) fn handle_config_mutation(&self) { + self.clear_plugin_related_caches(); } - pub(crate) async fn maybe_start_plugin_startup_tasks_for_latest_config(&self) { - match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => self - .thread_manager - .plugins_manager() - .maybe_start_plugin_startup_tasks_for_config( - &config, - self.thread_manager.auth_manager(), - ), - Err(err) => warn!("failed to load latest config for plugin startup tasks: {err:?}"), - } + fn clear_plugin_related_caches(&self) { + self.thread_manager.plugins_manager().clear_cache(); + self.thread_manager.skills_manager().clear_cache(); } fn current_account_updated_notification(&self) -> AccountUpdatedNotification { @@ -490,6 +523,7 @@ impl CodexMessageProcessor { auth_manager, thread_manager, outgoing, + analytics_events_client, arg0_paths, config, cli_overrides, @@ -502,6 +536,7 @@ impl CodexMessageProcessor { auth_manager, thread_manager, outgoing: outgoing.clone(), + analytics_events_client, arg0_paths, config, cli_overrides, @@ -537,6 +572,7 @@ impl CodexMessageProcessor { data: None, })?; apply_runtime_feature_enablement(&mut config, &self.current_runtime_feature_enablement()); + config.codex_self_exe = self.arg0_paths.codex_self_exe.clone(); config.codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); config.main_execve_wrapper_exe = self.arg0_paths.main_execve_wrapper_exe.clone(); Ok(config) @@ -650,6 +686,7 @@ impl CodexMessageProcessor { connection_id: ConnectionId, request: ClientRequest, app_server_client_name: Option, + app_server_client_version: Option, request_context: RequestContext, ) { let to_connection_request_id = |request_id| ConnectionRequestId { @@ -666,6 +703,8 @@ impl CodexMessageProcessor { self.thread_start( to_connection_request_id(request_id), params, + app_server_client_name.clone(), + app_server_client_version.clone(), request_context, ) .await; @@ -770,6 +809,7 @@ impl CodexMessageProcessor { to_connection_request_id(request_id), params, app_server_client_name.clone(), + app_server_client_version.clone(), ) .await; } @@ -844,6 +884,10 @@ impl CodexMessageProcessor { self.list_mcp_server_status(to_connection_request_id(request_id), params) .await; } + ClientRequest::McpResourceRead { request_id, params } => { + self.read_mcp_resource(to_connection_request_id(request_id), params) + .await; + } ClientRequest::WindowsSandboxSetupStart { request_id, params } => { self.windows_sandbox_setup_start(to_connection_request_id(request_id), params) .await; @@ -953,6 +997,9 @@ impl CodexMessageProcessor { LoginAccountParams::Chatgpt => { self.login_chatgpt_v2(request_id).await; } + LoginAccountParams::ChatgptDeviceCode => { + self.login_chatgpt_device_code_v2(request_id).await; + } LoginAccountParams::ChatgptAuthTokens { access_token, chatgpt_account_id, @@ -982,7 +1029,7 @@ impl CodexMessageProcessor { &mut self, params: &LoginApiKeyParams, ) -> std::result::Result<(), JSONRPCErrorError> { - if self.auth_manager.is_external_auth_active() { + if self.auth_manager.is_external_chatgpt_auth_active() { return Err(self.external_auth_active_error()); } @@ -1061,7 +1108,7 @@ impl CodexMessageProcessor { ) -> std::result::Result { let config = self.config.as_ref(); - if self.auth_manager.is_external_auth_active() { + if self.auth_manager.is_external_chatgpt_auth_active() { return Err(self.external_auth_active_error()); } @@ -1073,7 +1120,7 @@ impl CodexMessageProcessor { }); } - Ok(LoginServerOptions { + let mut opts = LoginServerOptions { open_browser: false, ..LoginServerOptions::new( config.codex_home.clone(), @@ -1081,7 +1128,32 @@ impl CodexMessageProcessor { config.forced_chatgpt_workspace_id.clone(), config.cli_auth_credentials_store_mode, ) - }) + }; + #[cfg(debug_assertions)] + if let Ok(issuer) = std::env::var(LOGIN_ISSUER_OVERRIDE_ENV_VAR) + && !issuer.trim().is_empty() + { + opts.issuer = issuer; + } + + Ok(opts) + } + + fn login_chatgpt_device_code_start_error(err: IoError) -> JSONRPCErrorError { + let is_not_found = err.kind() == std::io::ErrorKind::NotFound; + JSONRPCErrorError { + code: if is_not_found { + INVALID_REQUEST_ERROR_CODE + } else { + INTERNAL_ERROR_CODE + }, + message: if is_not_found { + err.to_string() + } else { + format!("failed to request device code: {err}") + }, + data: None, + } } async fn login_chatgpt_v2(&mut self, request_id: ConnectionRequestId) { @@ -1097,7 +1169,7 @@ impl CodexMessageProcessor { if let Some(existing) = guard.take() { drop(existing); } - *guard = Some(ActiveLogin { + *guard = Some(ActiveLogin::Browser { shutdown_handle: shutdown_handle.clone(), login_id, }); @@ -1167,7 +1239,7 @@ impl CodexMessageProcessor { // Clear the active login if it matches this attempt. It may have been replaced or cancelled. let mut guard = active_login.lock().await; - if guard.as_ref().map(|l| l.login_id) == Some(login_id) { + if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) { *guard = None; } }); @@ -1193,12 +1265,114 @@ impl CodexMessageProcessor { } } + async fn login_chatgpt_device_code_v2(&mut self, request_id: ConnectionRequestId) { + match self.login_chatgpt_common().await { + Ok(opts) => match request_device_code(&opts).await { + Ok(device_code) => { + let login_id = Uuid::new_v4(); + let cancel = CancellationToken::new(); + + { + let mut guard = self.active_login.lock().await; + if let Some(existing) = guard.take() { + drop(existing); + } + *guard = Some(ActiveLogin::DeviceCode { + cancel: cancel.clone(), + login_id, + }); + } + + let verification_url = device_code.verification_url.clone(); + let user_code = device_code.user_code.clone(); + let response = + codex_app_server_protocol::LoginAccountResponse::ChatgptDeviceCode { + login_id: login_id.to_string(), + verification_url, + user_code, + }; + self.outgoing.send_response(request_id, response).await; + + let outgoing_clone = self.outgoing.clone(); + let active_login = self.active_login.clone(); + let auth_manager = self.auth_manager.clone(); + let cloud_requirements = self.cloud_requirements.clone(); + let chatgpt_base_url = self.config.chatgpt_base_url.clone(); + let codex_home = self.config.codex_home.clone(); + let cli_overrides = self.current_cli_overrides(); + tokio::spawn(async move { + let (success, error_msg) = tokio::select! { + _ = cancel.cancelled() => { + (false, Some("Login was not completed".to_string())) + } + r = complete_device_code_login(opts, device_code) => { + match r { + Ok(()) => (true, None), + Err(err) => (false, Some(err.to_string())), + } + } + }; + + let payload_v2 = AccountLoginCompletedNotification { + login_id: Some(login_id.to_string()), + success, + error: error_msg, + }; + outgoing_clone + .send_server_notification(ServerNotification::AccountLoginCompleted( + payload_v2, + )) + .await; + + if success { + auth_manager.reload(); + replace_cloud_requirements_loader( + cloud_requirements.as_ref(), + auth_manager.clone(), + chatgpt_base_url, + codex_home, + ); + sync_default_client_residency_requirement( + &cli_overrides, + cloud_requirements.as_ref(), + ) + .await; + + let auth = auth_manager.auth_cached(); + let payload_v2 = AccountUpdatedNotification { + auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode), + plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type), + }; + outgoing_clone + .send_server_notification(ServerNotification::AccountUpdated( + payload_v2, + )) + .await; + } + + let mut guard = active_login.lock().await; + if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) { + *guard = None; + } + }); + } + Err(err) => { + let error = Self::login_chatgpt_device_code_start_error(err); + self.outgoing.send_error(request_id, error).await; + } + }, + Err(err) => { + self.outgoing.send_error(request_id, err).await; + } + } + } + async fn cancel_login_chatgpt_common( &mut self, login_id: Uuid, ) -> std::result::Result<(), CancelLoginError> { let mut guard = self.active_login.lock().await; - if guard.as_ref().map(|l| l.login_id) == Some(login_id) { + if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) { if let Some(active) = guard.take() { drop(active); } @@ -1371,7 +1545,7 @@ impl CodexMessageProcessor { } async fn refresh_token_if_requested(&self, do_refresh: bool) -> RefreshTokenRequestOutcome { - if self.auth_manager.is_external_auth_active() { + if self.auth_manager.is_external_chatgpt_auth_active() { return RefreshTokenRequestOutcome::NotAttemptedOrSucceeded; } if do_refresh && let Err(err) = self.auth_manager.refresh_token().await { @@ -1403,7 +1577,12 @@ impl CodexMessageProcessor { requires_openai_auth: Some(false), } } else { - match self.auth_manager.auth().await { + let auth = if do_refresh { + self.auth_manager.auth_cached() + } else { + self.auth_manager.auth().await + }; + match auth { Some(auth) => { let permanent_refresh_failure = self.auth_manager.refresh_failure_for_auth(&auth).is_some(); @@ -1881,6 +2060,8 @@ impl CodexMessageProcessor { &self, request_id: ConnectionRequestId, params: ThreadStartParams, + app_server_client_name: Option, + app_server_client_version: Option, request_context: RequestContext, ) { let ThreadStartParams { @@ -1921,6 +2102,8 @@ impl CodexMessageProcessor { thread_manager: Arc::clone(&self.thread_manager), thread_state_manager: self.thread_state_manager.clone(), outgoing: Arc::clone(&self.outgoing), + analytics_events_client: self.analytics_events_client.clone(), + general_analytics_enabled: self.config.features.enabled(Feature::GeneralAnalytics), thread_watch_manager: self.thread_watch_manager.clone(), fallback_model_provider: self.config.model_provider_id.clone(), codex_home: self.config.codex_home.clone(), @@ -1934,6 +2117,8 @@ impl CodexMessageProcessor { runtime_feature_enablement, cloud_requirements, request_id, + app_server_client_name, + app_server_client_version, config, typesafe_overrides, dynamic_tools, @@ -2007,6 +2192,8 @@ impl CodexMessageProcessor { runtime_feature_enablement: BTreeMap, cloud_requirements: CloudRequirementsLoader, request_id: ConnectionRequestId, + app_server_client_name: Option, + app_server_client_version: Option, config_overrides: Option>, typesafe_overrides: ConfigOverrides, dynamic_tools: Option>, @@ -2015,10 +2202,11 @@ impl CodexMessageProcessor { experimental_raw_events: bool, request_trace: Option, ) { - let config = match derive_config_from_params( + let requested_cwd = typesafe_overrides.cwd.clone(); + let mut config = match derive_config_from_params( &cli_overrides, - config_overrides, - typesafe_overrides, + config_overrides.clone(), + typesafe_overrides.clone(), &cloud_requirements, &listener_task_context.codex_home, &runtime_feature_enablement, @@ -2036,6 +2224,71 @@ impl CodexMessageProcessor { } }; + // The user may have requested WorkspaceWrite or DangerFullAccess via + // the command line, though in the process of deriving the Config, it + // could be downgraded to ReadOnly (perhaps there is no sandbox + // available on Windows or the enterprise config disallows it). The cwd + // should still be considered "trusted" in this case. + let requested_sandbox_trusts_project = matches!( + typesafe_overrides.sandbox_mode, + Some( + codex_protocol::config_types::SandboxMode::WorkspaceWrite + | codex_protocol::config_types::SandboxMode::DangerFullAccess + ) + ); + + if requested_cwd.is_some() + && !config.active_project.is_trusted() + && (requested_sandbox_trusts_project + || matches!( + config.permissions.sandbox_policy.get(), + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } + | codex_protocol::protocol::SandboxPolicy::DangerFullAccess + | codex_protocol::protocol::SandboxPolicy::ExternalSandbox { .. } + )) + { + let trust_target = resolve_root_git_project_for_trust(config.cwd.as_path()) + .await + .unwrap_or_else(|| config.cwd.to_path_buf()); + if let Err(err) = codex_core::config::set_project_trust_level( + &listener_task_context.codex_home, + trust_target.as_path(), + TrustLevel::Trusted, + ) { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to persist trusted project state: {err}"), + data: None, + }; + listener_task_context + .outgoing + .send_error(request_id, error) + .await; + return; + } + + config = match derive_config_from_params( + &cli_overrides, + config_overrides, + typesafe_overrides, + &cloud_requirements, + &listener_task_context.codex_home, + &runtime_feature_enablement, + ) + .await + { + Ok(config) => config, + Err(err) => { + let error = config_load_error(&err); + listener_task_context + .outgoing + .send_error(request_id, error) + .await; + return; + } + }; + } + let dynamic_tools = dynamic_tools.unwrap_or_default(); let core_dynamic_tools = if dynamic_tools.is_empty() { Vec::new() @@ -2088,6 +2341,19 @@ impl CodexMessageProcessor { session_configured, .. } = new_conv; + if let Err(error) = Self::set_app_server_client_info( + thread.as_ref(), + app_server_client_name, + app_server_client_version, + ) + .await + { + listener_task_context + .outgoing + .send_error(request_id, error) + .await; + return; + } let config_snapshot = thread .config_snapshot() .instrument(tracing::info_span!( @@ -2153,6 +2419,17 @@ impl CodexMessageProcessor { sandbox: config_snapshot.sandbox_policy.into(), reasoning_effort: config_snapshot.reasoning_effort, }; + if listener_task_context.general_analytics_enabled { + listener_task_context + .analytics_events_client + .track_response( + request_id.connection_id.0, + ClientResponse::ThreadStart { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } listener_task_context .outgoing @@ -2197,8 +2474,8 @@ impl CodexMessageProcessor { approval_policy: Option, approvals_reviewer: Option, sandbox: Option, - base_instructions: Option, - developer_instructions: Option, + base_instructions: Option>, + developer_instructions: Option>, personality: Option, ) -> ConfigOverrides { ConfigOverrides { @@ -3110,6 +3387,13 @@ impl CodexMessageProcessor { cwd, search_term, } = params; + let cwd = match normalize_thread_list_cwd_filter(cwd) { + Ok(cwd) => cwd, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; let requested_page_size = limit .map(|value| value as usize) @@ -3128,7 +3412,7 @@ impl CodexMessageProcessor { model_providers, source_kinds, archived: archived.unwrap_or(false), - cwd: cwd.map(PathBuf::from), + cwd, search_term, }, ) @@ -3337,6 +3621,11 @@ impl CodexMessageProcessor { } build_thread_from_snapshot(thread_uuid, &config_snapshot, loaded_rollout_path) }; + if thread.forked_from_id.is_none() + && let Some(rollout_path) = rollout_path.as_ref() + { + thread.forked_from_id = forked_from_id_from_rollout(rollout_path).await; + } self.attach_thread_name(thread_uuid, &mut thread).await; if include_turns && let Some(rollout_path) = rollout_path.as_ref() { @@ -3631,6 +3920,15 @@ impl CodexMessageProcessor { sandbox: session_configured.sandbox_policy.into(), reasoning_effort: session_configured.reasoning_effort, }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::ThreadResume { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; } @@ -4066,6 +4364,13 @@ impl CodexMessageProcessor { developer_instructions, /*personality*/ None, ); + if typesafe_overrides.base_instructions.is_none() + && let Ok(history) = RolloutRecorder::get_rollout_history(&rollout_path).await + && let Some(base_instructions) = history.get_base_instructions() + { + typesafe_overrides.base_instructions = + Some(base_instructions.map(|base_instructions| base_instructions.text)); + } typesafe_overrides.ephemeral = ephemeral.then_some(true); // Derive a Config using the same logic as new conversation, honoring overrides if provided. let cloud_requirements = self.current_cloud_requirements(); @@ -4157,7 +4462,12 @@ impl CodexMessageProcessor { ) .await { - Ok(summary) => summary_to_thread(summary), + Ok(summary) => { + let mut thread = summary_to_thread(summary); + thread.forked_from_id = + forked_from_id_from_rollout(fork_rollout_path.as_path()).await; + thread + } Err(err) => { self.send_internal_error( request_id, @@ -4191,6 +4501,14 @@ impl CodexMessageProcessor { } }; thread.preview = preview_from_rollout_items(&history_items); + thread.forked_from_id = source_thread_id + .or_else(|| { + history_items.iter().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => Some(meta_line.meta.id), + _ => None, + }) + }) + .map(|id| id.to_string()); if let Err(message) = populate_thread_turns( &mut thread, ThreadTurnSource::HistoryItems(&history_items), @@ -4238,6 +4556,15 @@ impl CodexMessageProcessor { sandbox: session_configured.sandbox_policy.into(), reasoning_effort: session_configured.reasoning_effort, }; + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_response( + request_id.connection_id.0, + ClientResponse::ThreadFork { + request_id: request_id.request_id.clone(), + response: response.clone(), + }, + ); + } self.outgoing.send_response(request_id, response).await; @@ -4835,9 +5162,12 @@ impl CodexMessageProcessor { return; } }; + let mcp_config = config.to_mcp_config(self.thread_manager.plugins_manager().as_ref()); + let auth = self.auth_manager.auth().await; tokio::spawn(async move { - Self::list_mcp_server_status_task(outgoing, request, params, config).await; + Self::list_mcp_server_status_task(outgoing, request, params, config, mcp_config, auth) + .await; }); } @@ -4846,15 +5176,72 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: ListMcpServerStatusParams, config: Config, + mcp_config: codex_mcp::McpConfig, + auth: Option, ) { - let snapshot = collect_mcp_snapshot(&config).await; + let detail = match params.detail.unwrap_or(McpServerStatusDetail::Full) { + McpServerStatusDetail::Full => McpSnapshotDetail::Full, + McpServerStatusDetail::ToolsAndAuthOnly => McpSnapshotDetail::ToolsAndAuthOnly, + }; - let tools_by_server = group_tools_by_server(&snapshot.tools); + let snapshot = collect_mcp_snapshot_with_detail( + &mcp_config, + auth.as_ref(), + request_id.request_id.to_string(), + detail, + ) + .await; + + // Rebuild the tool list per original server name instead of using + // `group_tools_by_server()`: qualified tool names are sanitized for the + // Responses API, so a config key like `some-server` is encoded as the + // `mcp__some_server__` prefix. Matching with the original server name's + // sanitized prefix preserves `/mcp` output for hyphenated names. + let effective_servers = effective_mcp_servers(&mcp_config, auth.as_ref()); + let mut sanitized_prefix_counts = HashMap::::new(); + for name in effective_servers.keys() { + let prefix = qualified_mcp_tool_name_prefix(name); + *sanitized_prefix_counts.entry(prefix).or_default() += 1; + } + let tools_by_server = effective_servers + .keys() + .map(|name| { + let prefix = qualified_mcp_tool_name_prefix(name); + // If multiple server names normalize to the same prefix, the + // qualified tool namespace is ambiguous (for example + // `some-server` and `some_server` both become + // `mcp__some_server__`). In that case, avoid attributing the + // same tools to multiple servers. + let tools = if sanitized_prefix_counts + .get(&prefix) + .copied() + .unwrap_or_default() + == 1 + { + snapshot + .tools + .iter() + .filter_map(|(qualified_name, tool)| { + qualified_name + .strip_prefix(&prefix) + .map(|tool_name| (tool_name.to_string(), tool.clone())) + }) + .collect::>() + } else { + HashMap::new() + }; + (name.clone(), tools) + }) + .collect::>(); let mut server_names: Vec = config .mcp_servers .keys() .cloned() + // Include built-in/plugin MCP servers that are present in the + // effective runtime config even when they are not user-declared in + // `config.mcp_servers`. + .chain(effective_servers.keys().cloned()) .chain(snapshot.auth_statuses.keys().cloned()) .chain(snapshot.resources.keys().cloned()) .chain(snapshot.resource_templates.keys().cloned()) @@ -4924,6 +5311,58 @@ impl CodexMessageProcessor { outgoing.send_response(request_id, response).await; } + async fn read_mcp_resource( + &self, + request_id: ConnectionRequestId, + params: McpResourceReadParams, + ) { + let outgoing = Arc::clone(&self.outgoing); + let (_, thread) = match self.load_thread(¶ms.thread_id).await { + Ok(thread) => thread, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + tokio::spawn(async move { + let result = thread.read_mcp_resource(¶ms.server, ¶ms.uri).await; + match result { + Ok(result) => match serde_json::from_value::(result) { + Ok(response) => { + outgoing.send_response(request_id, response).await; + } + Err(error) => { + outgoing + .send_error( + request_id, + JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!( + "failed to deserialize MCP resource read response: {error}" + ), + data: None, + }, + ) + .await; + } + }, + Err(error) => { + outgoing + .send_error( + request_id, + JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("{error:#}"), + data: None, + }, + ) + .await; + } + } + }); + } + async fn send_invalid_request_error(&self, request_id: ConnectionRequestId, message: String) { let error = JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, @@ -5259,7 +5698,11 @@ impl CodexMessageProcessor { .set_enabled(Feature::Apps, thread.enabled(Feature::Apps)); } - if !config.features.apps_enabled(Some(&self.auth_manager)).await { + let auth = self.auth_manager.auth().await; + if !config + .features + .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth)) + { self.outgoing .send_response( request_id, @@ -5590,6 +6033,7 @@ impl CodexMessageProcessor { force_remote_sync, } = params; let roots = cwds.unwrap_or_default(); + plugins_manager.maybe_start_non_curated_plugin_cache_refresh_for_roots(&roots); let mut config = match self.load_latest_config(/*fallback_cwd*/ None).await { Ok(config) => config, @@ -5926,9 +6370,11 @@ impl CodexMessageProcessor { } let plugin_apps = load_plugin_apps(result.installed_path.as_path()); + let auth = self.auth_manager.auth().await; let apps_needing_auth = if plugin_apps.is_empty() - || !config.features.apps_enabled(Some(&self.auth_manager)).await - { + || !config.features.apps_enabled_for_auth( + auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth), + ) { Vec::new() } else { let (all_connectors_result, accessible_connectors_result) = tokio::join!( @@ -6123,6 +6569,7 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: TurnStartParams, app_server_client_name: Option, + app_server_client_version: Option, ) { if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { self.outgoing.send_error(request_id, error).await; @@ -6135,8 +6582,12 @@ impl CodexMessageProcessor { return; } }; - if let Err(error) = - Self::set_app_server_client_name(thread.as_ref(), app_server_client_name).await + if let Err(error) = Self::set_app_server_client_info( + thread.as_ref(), + app_server_client_name, + app_server_client_version, + ) + .await { self.outgoing.send_error(request_id, error).await; return; @@ -6214,6 +6665,9 @@ impl CodexMessageProcessor { items: vec![], error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, }; let response = TurnStartResponse { turn }; @@ -6230,16 +6684,17 @@ impl CodexMessageProcessor { } } - async fn set_app_server_client_name( + async fn set_app_server_client_info( thread: &CodexThread, app_server_client_name: Option, + app_server_client_version: Option, ) -> Result<(), JSONRPCErrorError> { thread - .set_app_server_client_name(app_server_client_name) + .set_app_server_client_info(app_server_client_name, app_server_client_version) .await .map_err(|err| JSONRPCErrorError { code: INTERNAL_ERROR_CODE, - message: format!("failed to set app server client name: {err}"), + message: format!("failed to set app server client info: {err}"), data: None, }) } @@ -6549,6 +7004,9 @@ impl CodexMessageProcessor { items, error: None, status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, } } @@ -6819,6 +7277,8 @@ impl CodexMessageProcessor { thread_manager: Arc::clone(&self.thread_manager), thread_state_manager: self.thread_state_manager.clone(), outgoing: Arc::clone(&self.outgoing), + analytics_events_client: self.analytics_events_client.clone(), + general_analytics_enabled: self.config.features.enabled(Feature::GeneralAnalytics), thread_watch_manager: self.thread_watch_manager.clone(), fallback_model_provider: self.config.model_provider_id.clone(), codex_home: self.config.codex_home.clone(), @@ -6906,6 +7366,8 @@ impl CodexMessageProcessor { thread_manager: Arc::clone(&self.thread_manager), thread_state_manager: self.thread_state_manager.clone(), outgoing: Arc::clone(&self.outgoing), + analytics_events_client: self.analytics_events_client.clone(), + general_analytics_enabled: self.config.features.enabled(Feature::GeneralAnalytics), thread_watch_manager: self.thread_watch_manager.clone(), fallback_model_provider: self.config.model_provider_id.clone(), codex_home: self.config.codex_home.clone(), @@ -6937,6 +7399,8 @@ impl CodexMessageProcessor { outgoing, thread_manager, thread_state_manager, + analytics_events_client: _, + general_analytics_enabled: _, thread_watch_manager, fallback_model_provider, codex_home, @@ -7209,42 +7673,101 @@ impl CodexMessageProcessor { } let snapshot = self.feedback.snapshot(conversation_id); let thread_id = snapshot.thread_id.clone(); - let sqlite_feedback_logs = if include_logs { + let (feedback_thread_ids, sqlite_feedback_logs, state_db_ctx) = if include_logs { if let Some(log_db) = self.log_db.as_ref() { log_db.flush().await; } let state_db_ctx = get_state_db(&self.config).await; - match (state_db_ctx.as_ref(), conversation_id) { - (Some(state_db_ctx), Some(conversation_id)) => { - let thread_id_text = conversation_id.to_string(); - match state_db_ctx.query_feedback_logs(&thread_id_text).await { - Ok(logs) if logs.is_empty() => None, - Ok(logs) => Some(logs), - Err(err) => { - warn!( - "failed to query feedback logs from sqlite for thread_id={thread_id_text}: {err}" - ); - None + let feedback_thread_ids = match conversation_id { + Some(conversation_id) => match self + .thread_manager + .list_agent_subtree_thread_ids(conversation_id) + .await + { + Ok(thread_ids) => thread_ids, + Err(err) => { + warn!( + "failed to list feedback subtree for thread_id={conversation_id}: {err}" + ); + let mut thread_ids = vec![conversation_id]; + if let Some(state_db_ctx) = state_db_ctx.as_ref() { + for status in [ + codex_state::DirectionalThreadSpawnEdgeStatus::Open, + codex_state::DirectionalThreadSpawnEdgeStatus::Closed, + ] { + match state_db_ctx + .list_thread_spawn_descendants_with_status( + conversation_id, + status, + ) + .await + { + Ok(descendant_ids) => thread_ids.extend(descendant_ids), + Err(err) => warn!( + "failed to list persisted feedback subtree for thread_id={conversation_id}: {err}" + ), + } + } } + thread_ids + } + }, + None => Vec::new(), + }; + let sqlite_feedback_logs = if let Some(state_db_ctx) = state_db_ctx.as_ref() + && !feedback_thread_ids.is_empty() + { + let thread_id_texts = feedback_thread_ids + .iter() + .map(ToString::to_string) + .collect::>(); + let thread_id_refs = thread_id_texts + .iter() + .map(String::as_str) + .collect::>(); + match state_db_ctx + .query_feedback_logs_for_threads(&thread_id_refs) + .await + { + Ok(logs) if logs.is_empty() => None, + Ok(logs) => Some(logs), + Err(err) => { + let thread_ids = thread_id_texts.join(", "); + warn!( + "failed to query feedback logs from sqlite for thread_ids=[{thread_ids}]: {err}" + ); + None } } - _ => None, - } + } else { + None + }; + (feedback_thread_ids, sqlite_feedback_logs, state_db_ctx) } else { - None + (Vec::new(), None, None) }; - let validated_rollout_path = if include_logs { - match conversation_id { - Some(conv_id) => self.resolve_rollout_path(conv_id).await, - None => None, + let mut attachment_paths = Vec::new(); + let mut seen_attachment_paths = HashSet::new(); + if include_logs { + for feedback_thread_id in &feedback_thread_ids { + let Some(rollout_path) = self + .resolve_rollout_path(*feedback_thread_id, state_db_ctx.as_ref()) + .await + else { + continue; + }; + if seen_attachment_paths.insert(rollout_path.clone()) { + attachment_paths.push(rollout_path); + } } - } else { - None - }; - let mut attachment_paths = validated_rollout_path.into_iter().collect::>(); + } if let Some(extra_log_files) = extra_log_files { - attachment_paths.extend(extra_log_files); + for extra_log_file in extra_log_files { + if seen_attachment_paths.insert(extra_log_file.clone()) { + attachment_paths.push(extra_log_file); + } + } } let session_source = self.thread_manager.session_source(); @@ -7363,11 +7886,76 @@ impl CodexMessageProcessor { }); } - async fn resolve_rollout_path(&self, conversation_id: ThreadId) -> Option { - match self.thread_manager.get_thread(conversation_id).await { - Ok(conv) => conv.rollout_path(), - Err(_) => None, + async fn resolve_rollout_path( + &self, + conversation_id: ThreadId, + state_db_ctx: Option<&StateDbHandle>, + ) -> Option { + if let Ok(conversation) = self.thread_manager.get_thread(conversation_id).await + && let Some(rollout_path) = conversation.rollout_path() + { + return Some(rollout_path); } + + let state_db_ctx = state_db_ctx?; + state_db_ctx + .find_rollout_path_by_id(conversation_id, /*archived_only*/ None) + .await + .unwrap_or_else(|err| { + warn!("failed to resolve rollout path for thread_id={conversation_id}: {err}"); + None + }) + } +} + +fn normalize_thread_list_cwd_filter( + cwd: Option, +) -> Result, JSONRPCErrorError> { + let Some(cwd) = cwd else { + return Ok(None); + }; + AbsolutePathBuf::relative_to_current_dir(cwd.as_str()) + .map(AbsolutePathBuf::into_path_buf) + .map(Some) + .map_err(|err| JSONRPCErrorError { + code: INVALID_PARAMS_ERROR_CODE, + message: format!("invalid thread/list cwd filter `{cwd}`: {err}"), + data: None, + }) +} + +#[cfg(test)] +mod thread_list_cwd_filter_tests { + use super::normalize_thread_list_cwd_filter; + use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + + #[test] + fn normalize_thread_list_cwd_filter_preserves_absolute_paths() { + let cwd = if cfg!(windows) { + String::from(r"C:\srv\repo-b") + } else { + String::from("/srv/repo-b") + }; + + assert_eq!( + normalize_thread_list_cwd_filter(Some(cwd.clone())).expect("cwd filter should parse"), + Some(PathBuf::from(cwd)) + ); + } + + #[test] + fn normalize_thread_list_cwd_filter_resolves_relative_paths_against_server_cwd() + -> std::io::Result<()> { + let expected = AbsolutePathBuf::relative_to_current_dir("repo-b")?.to_path_buf(); + + assert_eq!( + normalize_thread_list_cwd_filter(Some(String::from("repo-b"))) + .expect("cwd filter should parse"), + Some(expected) + ); + Ok(()) } } @@ -7897,7 +8485,7 @@ fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> { return Err(format!("duplicate dynamic tool name: {name}")); } - if let Err(err) = codex_core::parse_tool_input_schema(&tool.input_schema) { + if let Err(err) = codex_tools::parse_tool_input_schema(&tool.input_schema) { return Err(format!( "dynamic tool input schema is not supported for {name}: {err}" )); @@ -8346,6 +8934,7 @@ async fn load_thread_summary_for_rollout( rollout_path.display() ) })?; + thread.forked_from_id = forked_from_id_from_rollout(rollout_path).await; if let Some(persisted_metadata) = persisted_metadata { merge_mutable_thread_metadata( &mut thread, @@ -8357,6 +8946,14 @@ async fn load_thread_summary_for_rollout( Ok(thread) } +async fn forked_from_id_from_rollout(path: &Path) -> Option { + read_session_meta_line(path) + .await + .ok() + .and_then(|meta_line| meta_line.meta.forked_from_id) + .map(|thread_id| thread_id.to_string()) +} + fn merge_mutable_thread_metadata(thread: &mut Thread, persisted_thread: Thread) { thread.git_info = persisted_thread.git_info; } @@ -8437,6 +9034,7 @@ fn build_thread_from_snapshot( let now = time::OffsetDateTime::now_utc().unix_timestamp(); Thread { id: thread_id.to_string(), + forked_from_id: None, preview: String::new(), ephemeral: config_snapshot.ephemeral, model_provider: config_snapshot.model_provider_id.clone(), @@ -8479,6 +9077,7 @@ pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread { Thread { id: conversation_id.to_string(), + forked_from_id: None, preview, ephemeral: false, model_provider, @@ -8582,7 +9181,7 @@ mod tests { fn config_load_error_marks_non_auth_cloud_requirements_failures_without_relogin() { let err = std::io::Error::other(CloudRequirementsLoadError::new( CloudRequirementsLoadErrorCode::RequestFailed, - None, + /*status_code*/ None, "failed to load your workspace-managed config", )); @@ -8796,7 +9395,8 @@ mod tests { fn merge_persisted_resume_metadata_skips_missing_values() -> Result<()> { let mut request_overrides = None; let mut typesafe_overrides = ConfigOverrides::default(); - let persisted_metadata = test_thread_metadata(None, None)?; + let persisted_metadata = + test_thread_metadata(/*model*/ None, /*reasoning_effort*/ None)?; merge_persisted_resume_metadata( &mut request_overrides, @@ -8848,7 +9448,7 @@ mod tests { path.clone(), &head, &session_meta, - None, + /*git*/ None, "test-provider", timestamp.clone(), ) @@ -8974,6 +9574,44 @@ mod tests { Ok(()) } + #[tokio::test] + async fn read_summary_from_rollout_preserves_forked_from_id() -> Result<()> { + use codex_protocol::protocol::RolloutItem; + use codex_protocol::protocol::RolloutLine; + use codex_protocol::protocol::SessionMetaLine; + use std::fs; + + let temp_dir = TempDir::new()?; + let path = temp_dir.path().join("rollout.jsonl"); + + let conversation_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; + let forked_from_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; + let timestamp = "2025-09-05T16:53:11.850Z".to_string(); + + let session_meta = SessionMeta { + id: conversation_id, + forked_from_id: Some(forked_from_id), + timestamp: timestamp.clone(), + model_provider: Some("test-provider".to_string()), + ..SessionMeta::default() + }; + + let line = RolloutLine { + timestamp, + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: session_meta, + git: None, + }), + }; + fs::write(&path, format!("{}\n", serde_json::to_string(&line)?))?; + + assert_eq!( + forked_from_id_from_rollout(path.as_path()).await, + Some(forked_from_id.to_string()) + ); + Ok(()) + } + #[tokio::test] async fn aborting_pending_request_clears_pending_state() -> Result<()> { let thread_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; @@ -9058,9 +9696,9 @@ mod tests { source, Some("atlas".to_string()), Some("explorer".to_string()), - None, - None, - None, + /*git_sha*/ None, + /*git_branch*/ None, + /*git_origin_url*/ None, ); let thread = summary_to_thread(summary); @@ -9079,7 +9717,9 @@ mod tests { manager.connection_initialized(connection).await; manager - .try_ensure_connection_subscribed(thread_id, connection, false) + .try_ensure_connection_subscribed( + thread_id, connection, /*experimental_raw_events*/ false, + ) .await .expect("connection should be live"); { @@ -9089,6 +9729,7 @@ mod tests { state.track_current_turn_event(&EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }, @@ -9123,11 +9764,19 @@ mod tests { manager.connection_initialized(connection_a).await; manager.connection_initialized(connection_b).await; manager - .try_ensure_connection_subscribed(thread_id, connection_a, false) + .try_ensure_connection_subscribed( + thread_id, + connection_a, + /*experimental_raw_events*/ false, + ) .await .expect("connection_a should be live"); manager - .try_ensure_connection_subscribed(thread_id, connection_b, false) + .try_ensure_connection_subscribed( + thread_id, + connection_b, + /*experimental_raw_events*/ false, + ) .await .expect("connection_b should be live"); { @@ -9160,7 +9809,9 @@ mod tests { assert!( manager - .try_ensure_connection_subscribed(thread_id, connection, false) + .try_ensure_connection_subscribed( + thread_id, connection, /*experimental_raw_events*/ false + ) .await .is_none() ); diff --git a/codex-rs/app-server/src/codex_message_processor/plugin_app_helpers.rs b/codex-rs/app-server/src/codex_message_processor/plugin_app_helpers.rs index faeca5b2e0..f2ba96d43a 100644 --- a/codex-rs/app-server/src/codex_message_processor/plugin_app_helpers.rs +++ b/codex-rs/app-server/src/codex_message_processor/plugin_app_helpers.rs @@ -137,7 +137,7 @@ mod tests { &all_connectors, &[], &[AppConnectorId("alpha".to_string())], - false, + /*codex_apps_ready*/ false, ), Vec::new() ); diff --git a/codex-rs/app-server/src/codex_message_processor/plugin_mcp_oauth.rs b/codex-rs/app-server/src/codex_message_processor/plugin_mcp_oauth.rs index fb9973e8c5..b027aef453 100644 --- a/codex-rs/app-server/src/codex_message_processor/plugin_mcp_oauth.rs +++ b/codex-rs/app-server/src/codex_message_processor/plugin_mcp_oauth.rs @@ -3,12 +3,12 @@ use std::sync::Arc; use codex_app_server_protocol::McpServerOauthLoginCompletedNotification; use codex_app_server_protocol::ServerNotification; +use codex_config::types::McpServerConfig; use codex_core::config::Config; -use codex_core::config::types::McpServerConfig; -use codex_core::mcp::auth::McpOAuthLoginSupport; -use codex_core::mcp::auth::oauth_login_support; -use codex_core::mcp::auth::resolve_oauth_scopes; -use codex_core::mcp::auth::should_retry_without_scopes; +use codex_mcp::McpOAuthLoginSupport; +use codex_mcp::oauth_login_support; +use codex_mcp::resolve_oauth_scopes; +use codex_mcp::should_retry_without_scopes; use codex_rmcp_client::perform_oauth_login_silent; use tracing::warn; diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index a1a042de6a..bb086e0dd0 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -18,12 +18,12 @@ use codex_app_server_protocol::CommandExecWriteParams; use codex_app_server_protocol::CommandExecWriteResponse; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::ServerNotification; -use codex_core::bytes_to_string_smart; use codex_core::config::StartedNetworkProxy; use codex_core::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS; use codex_core::exec::ExecExpiration; use codex_core::exec::IO_DRAIN_TIMEOUT_MS; use codex_core::sandboxing::ExecRequest; +use codex_protocol::exec_output::bytes_to_string_smart; use codex_sandboxing::SandboxType; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; use codex_utils_pty::ProcessHandle; diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 2be0bb859c..4f9a800243 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -1,6 +1,7 @@ use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; use async_trait::async_trait; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigReadResponse; @@ -12,9 +13,10 @@ use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse; use codex_app_server_protocol::JSONRPCErrorError; +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_core::AnalyticsEventsClient; use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::config::ConfigService; @@ -130,7 +132,7 @@ impl ConfigApi { .unwrap_or_default() } - async fn load_latest_config( + pub(crate) async fn load_latest_config( &self, fallback_cwd: Option, ) -> Result { @@ -364,6 +366,12 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR .map(codex_app_server_protocol::AskForApproval::from) .collect() }), + allowed_approvals_reviewers: requirements.allowed_approvals_reviewers.map(|reviewers| { + reviewers + .into_iter() + .map(codex_app_server_protocol::ApprovalsReviewer::from) + .collect() + }), allowed_sandbox_modes: requirements.allowed_sandbox_modes.map(|modes| { modes .into_iter() @@ -410,6 +418,20 @@ fn map_residency_requirement_to_api( fn map_network_requirements_to_api( network: codex_core::config_loader::NetworkRequirementsToml, ) -> NetworkRequirements { + let allowed_domains = network + .domains + .as_ref() + .and_then(codex_core::config_loader::NetworkDomainPermissionsToml::allowed_domains); + let denied_domains = network + .domains + .as_ref() + .and_then(codex_core::config_loader::NetworkDomainPermissionsToml::denied_domains); + let allow_unix_sockets = network + .unix_sockets + .as_ref() + .map(codex_core::config_loader::NetworkUnixSocketPermissionsToml::allow_unix_sockets) + .filter(|entries| !entries.is_empty()); + NetworkRequirements { enabled: network.enabled, http_port: network.http_port, @@ -417,13 +439,59 @@ fn map_network_requirements_to_api( allow_upstream_proxy: network.allow_upstream_proxy, dangerously_allow_non_loopback_proxy: network.dangerously_allow_non_loopback_proxy, dangerously_allow_all_unix_sockets: network.dangerously_allow_all_unix_sockets, - allowed_domains: network.allowed_domains, - denied_domains: network.denied_domains, - allow_unix_sockets: network.allow_unix_sockets, + domains: network.domains.map(|domains| { + domains + .entries + .into_iter() + .map(|(pattern, permission)| { + (pattern, map_network_domain_permission_to_api(permission)) + }) + .collect() + }), + managed_allowed_domains_only: network.managed_allowed_domains_only, + danger_full_access_denylist_only: network.danger_full_access_denylist_only, + allowed_domains, + denied_domains, + unix_sockets: network.unix_sockets.map(|unix_sockets| { + unix_sockets + .entries + .into_iter() + .map(|(path, permission)| { + (path, map_network_unix_socket_permission_to_api(permission)) + }) + .collect() + }), + allow_unix_sockets, allow_local_binding: network.allow_local_binding, } } +fn map_network_domain_permission_to_api( + permission: codex_core::config_loader::NetworkDomainPermissionToml, +) -> NetworkDomainPermission { + match permission { + codex_core::config_loader::NetworkDomainPermissionToml::Allow => { + NetworkDomainPermission::Allow + } + codex_core::config_loader::NetworkDomainPermissionToml::Deny => { + NetworkDomainPermission::Deny + } + } +} + +fn map_network_unix_socket_permission_to_api( + permission: codex_core::config_loader::NetworkUnixSocketPermissionToml, +) -> NetworkUnixSocketPermission { + match permission { + codex_core::config_loader::NetworkUnixSocketPermissionToml::Allow => { + NetworkUnixSocketPermission::Allow + } + codex_core::config_loader::NetworkUnixSocketPermissionToml::None => { + NetworkUnixSocketPermission::None + } + } +} + fn map_error(err: ConfigServiceError) -> JSONRPCErrorError { if let Some(code) = err.write_error_code() { return config_write_error(code, err.to_string()); @@ -449,11 +517,16 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> #[cfg(test)] mod tests { use super::*; - use codex_core::AnalyticsEventsClient; - use codex_core::AuthManager; - use codex_core::CodexAuth; + use codex_analytics::AnalyticsEventsClient; + 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_features::Feature; + use codex_login::AuthManager; + use codex_login::CodexAuth; + use codex_protocol::config_types::ApprovalsReviewer as CoreApprovalsReviewer; use codex_protocol::protocol::AskForApproval as CoreAskForApproval; use pretty_assertions::assert_eq; use serde_json::json; @@ -480,6 +553,10 @@ mod tests { CoreAskForApproval::Never, CoreAskForApproval::OnRequest, ]), + allowed_approvals_reviewers: Some(vec![ + CoreApprovalsReviewer::User, + CoreApprovalsReviewer::GuardianSubagent, + ]), allowed_sandbox_modes: Some(vec![ CoreSandboxModeRequirement::ReadOnly, CoreSandboxModeRequirement::ExternalSandbox, @@ -505,10 +582,26 @@ mod tests { allow_upstream_proxy: Some(false), dangerously_allow_non_loopback_proxy: Some(false), dangerously_allow_all_unix_sockets: Some(true), - allowed_domains: Some(vec!["api.openai.com".to_string()]), + domains: Some(CoreNetworkDomainPermissionsToml { + entries: std::collections::BTreeMap::from([ + ( + "api.openai.com".to_string(), + CoreNetworkDomainPermissionToml::Allow, + ), + ( + "example.com".to_string(), + CoreNetworkDomainPermissionToml::Deny, + ), + ]), + }), managed_allowed_domains_only: Some(false), - denied_domains: Some(vec!["example.com".to_string()]), - allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), + danger_full_access_denylist_only: Some(true), + unix_sockets: Some(CoreNetworkUnixSocketPermissionsToml { + entries: std::collections::BTreeMap::from([( + "/tmp/proxy.sock".to_string(), + CoreNetworkUnixSocketPermissionToml::Allow, + )]), + }), allow_local_binding: Some(true), }), }; @@ -522,6 +615,13 @@ mod tests { codex_app_server_protocol::AskForApproval::OnRequest, ]) ); + assert_eq!( + mapped.allowed_approvals_reviewers, + Some(vec![ + codex_app_server_protocol::ApprovalsReviewer::User, + codex_app_server_protocol::ApprovalsReviewer::GuardianSubagent, + ]) + ); assert_eq!( mapped.allowed_sandbox_modes, Some(vec![SandboxMode::ReadOnly]), @@ -550,18 +650,88 @@ mod tests { allow_upstream_proxy: Some(false), dangerously_allow_non_loopback_proxy: Some(false), dangerously_allow_all_unix_sockets: Some(true), + domains: Some(std::collections::BTreeMap::from([ + ("api.openai.com".to_string(), NetworkDomainPermission::Allow,), + ("example.com".to_string(), NetworkDomainPermission::Deny), + ])), + managed_allowed_domains_only: Some(false), + danger_full_access_denylist_only: Some(true), allowed_domains: Some(vec!["api.openai.com".to_string()]), denied_domains: Some(vec!["example.com".to_string()]), + unix_sockets: Some(std::collections::BTreeMap::from([( + "/tmp/proxy.sock".to_string(), + NetworkUnixSocketPermission::Allow, + )])), allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), allow_local_binding: Some(true), }), ); } + #[test] + fn map_requirements_toml_to_api_omits_unix_socket_none_entries_from_legacy_network_fields() { + let requirements = ConfigRequirementsToml { + allowed_approval_policies: None, + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + allowed_web_search_modes: None, + guardian_developer_instructions: None, + feature_requirements: None, + mcp_servers: None, + apps: None, + rules: None, + enforce_residency: None, + network: Some(CoreNetworkRequirementsToml { + enabled: None, + http_port: None, + socks_port: None, + allow_upstream_proxy: None, + dangerously_allow_non_loopback_proxy: None, + dangerously_allow_all_unix_sockets: None, + domains: None, + managed_allowed_domains_only: None, + danger_full_access_denylist_only: None, + unix_sockets: Some(CoreNetworkUnixSocketPermissionsToml { + entries: std::collections::BTreeMap::from([( + "/tmp/ignored.sock".to_string(), + CoreNetworkUnixSocketPermissionToml::None, + )]), + }), + allow_local_binding: None, + }), + }; + + let mapped = map_requirements_toml_to_api(requirements); + + assert_eq!( + mapped.network, + Some(NetworkRequirements { + enabled: None, + http_port: None, + socks_port: None, + allow_upstream_proxy: None, + dangerously_allow_non_loopback_proxy: None, + dangerously_allow_all_unix_sockets: None, + domains: None, + managed_allowed_domains_only: None, + danger_full_access_denylist_only: None, + allowed_domains: None, + denied_domains: None, + unix_sockets: Some(std::collections::BTreeMap::from([( + "/tmp/ignored.sock".to_string(), + NetworkUnixSocketPermission::None, + )])), + allow_unix_sockets: None, + allow_local_binding: None, + }), + ); + } + #[test] fn map_requirements_toml_to_api_normalizes_allowed_web_search_modes() { let requirements = ConfigRequirementsToml { allowed_approval_policies: None, + allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: Some(Vec::new()), guardian_developer_instructions: None, diff --git a/codex-rs/app-server/src/filters.rs b/codex-rs/app-server/src/filters.rs index 6d2b90dbae..20608d93cd 100644 --- a/codex-rs/app-server/src/filters.rs +++ b/codex-rs/app-server/src/filters.rs @@ -90,7 +90,7 @@ mod tests { #[test] fn compute_source_filters_defaults_to_interactive_sources() { - let (allowed_sources, filter) = compute_source_filters(None); + let (allowed_sources, filter) = compute_source_filters(/*source_kinds*/ None); assert_eq!(allowed_sources, INTERACTIVE_SESSION_SOURCES.to_vec()); assert_eq!(filter, None); diff --git a/codex-rs/app-server/src/fs_api.rs b/codex-rs/app-server/src/fs_api.rs index 0fd53cf9ac..9e0263e344 100644 --- a/codex-rs/app-server/src/fs_api.rs +++ b/codex-rs/app-server/src/fs_api.rs @@ -34,7 +34,7 @@ pub(crate) struct FsApi { impl Default for FsApi { fn default() -> Self { Self { - file_system: Arc::new(LOCAL_FS), + file_system: Arc::clone(&LOCAL_FS), } } } diff --git a/codex-rs/app-server/src/fs_watch.rs b/codex-rs/app-server/src/fs_watch.rs index 309bee4a64..3a5b226248 100644 --- a/codex-rs/app-server/src/fs_watch.rs +++ b/codex-rs/app-server/src/fs_watch.rs @@ -1,3 +1,4 @@ +use crate::fs_api::invalid_request; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::OutgoingMessageSender; use codex_app_server_protocol::FsChangedNotification; @@ -16,6 +17,7 @@ use codex_core::file_watcher::WatchRegistration; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; use std::collections::HashSet; +use std::collections::hash_map::Entry; use std::hash::Hash; use std::path::PathBuf; use std::sync::Arc; @@ -26,7 +28,6 @@ use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::time::Instant; use tracing::warn; -use uuid::Uuid; const FS_CHANGED_NOTIFICATION_DEBOUNCE: Duration = Duration::from_millis(200); @@ -120,7 +121,11 @@ impl FsWatchManager { connection_id: ConnectionId, params: FsWatchParams, ) -> Result { - let watch_id = Uuid::now_v7().to_string(); + let watch_id = params.watch_id; + let watch_key = WatchKey { + connection_id, + watch_id: watch_id.clone(), + }; let outgoing = self.outgoing.clone(); let (subscriber, rx) = self.file_watcher.add_subscriber(); let watch_root = params.path.to_path_buf().clone(); @@ -130,17 +135,20 @@ impl FsWatchManager { }]); let (terminate_tx, terminate_rx) = oneshot::channel(); - self.state.lock().await.entries.insert( - WatchKey { - connection_id, - watch_id: watch_id.clone(), - }, - WatchEntry { - terminate_tx, - _subscriber: subscriber, - _registration: registration, - }, - ); + match self.state.lock().await.entries.entry(watch_key) { + Entry::Occupied(_) => { + return Err(invalid_request(format!( + "watchId already exists: {watch_id}" + ))); + } + Entry::Vacant(entry) => { + entry.insert(WatchEntry { + terminate_tx, + _subscriber: subscriber, + _registration: registration, + }); + } + } let task_watch_id = watch_id.clone(); tokio::spawn(async move { @@ -158,19 +166,7 @@ impl FsWatchManager { let mut changed_paths = event .paths .into_iter() - .filter_map(|path| { - match AbsolutePathBuf::resolve_path_against_base(&path, &watch_root) { - Ok(path) => Some(path), - Err(err) => { - warn!( - "failed to normalize watch event path ({}) for {}: {err}", - path.display(), - watch_root.display() - ); - None - } - } - }) + .map(|path| AbsolutePathBuf::resolve_path_against_base(&path, &watch_root)) .collect::>(); changed_paths.sort_by(|left, right| left.as_path().cmp(right.as_path())); if !changed_paths.is_empty() { @@ -187,10 +183,7 @@ impl FsWatchManager { } }); - Ok(FsWatchResponse { - watch_id, - path: params.path, - }) + Ok(FsWatchResponse { path: params.path }) } pub(crate) async fn unwatch( @@ -228,7 +221,6 @@ mod tests { use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use tempfile::TempDir; - use uuid::Version; fn absolute_path(path: PathBuf) -> AbsolutePathBuf { assert!( @@ -249,28 +241,33 @@ mod tests { } #[tokio::test] - async fn watch_returns_a_v7_id_and_tracks_the_owner_scoped_entry() { + async fn watch_uses_client_id_and_tracks_the_owner_scoped_entry() { let temp_dir = TempDir::new().expect("temp dir"); let head_path = temp_dir.path().join("HEAD"); std::fs::write(&head_path, "ref: refs/heads/main\n").expect("write HEAD"); let manager = manager_with_noop_watcher(); let path = absolute_path(head_path); + let watch_id = "watch-head".to_string(); let response = manager - .watch(ConnectionId(1), FsWatchParams { path: path.clone() }) + .watch( + ConnectionId(1), + FsWatchParams { + watch_id: watch_id.clone(), + path: path.clone(), + }, + ) .await .expect("watch should succeed"); assert_eq!(response.path, path); - let watch_id = Uuid::parse_str(&response.watch_id).expect("watch id should be a UUID"); - assert_eq!(watch_id.get_version(), Some(Version::SortRand)); let state = manager.state.lock().await; assert_eq!( state.entries.keys().cloned().collect::>(), HashSet::from([WatchKey { connection_id: ConnectionId(1), - watch_id: response.watch_id, + watch_id, }]) ); } @@ -282,10 +279,11 @@ mod tests { std::fs::write(&head_path, "ref: refs/heads/main\n").expect("write HEAD"); let manager = manager_with_noop_watcher(); - let response = manager + manager .watch( ConnectionId(1), FsWatchParams { + watch_id: "watch-head".to_string(), path: absolute_path(head_path), }, ) @@ -293,14 +291,14 @@ mod tests { .expect("watch should succeed"); let watch_key = WatchKey { connection_id: ConnectionId(1), - watch_id: response.watch_id.clone(), + watch_id: "watch-head".to_string(), }; manager .unwatch( ConnectionId(2), FsUnwatchParams { - watch_id: response.watch_id.clone(), + watch_id: "watch-head".to_string(), }, ) .await @@ -311,7 +309,7 @@ mod tests { .unwatch( ConnectionId(1), FsUnwatchParams { - watch_id: response.watch_id, + watch_id: "watch-head".to_string(), }, ) .await @@ -319,6 +317,41 @@ mod tests { assert!(!manager.state.lock().await.entries.contains_key(&watch_key)); } + #[tokio::test] + async fn watch_rejects_duplicate_id_for_the_same_connection() { + let temp_dir = TempDir::new().expect("temp dir"); + let head_path = temp_dir.path().join("HEAD"); + let fetch_head_path = temp_dir.path().join("FETCH_HEAD"); + std::fs::write(&head_path, "ref: refs/heads/main\n").expect("write HEAD"); + std::fs::write(&fetch_head_path, "old-fetch\n").expect("write FETCH_HEAD"); + + let manager = manager_with_noop_watcher(); + manager + .watch( + ConnectionId(1), + FsWatchParams { + watch_id: "watch-head".to_string(), + path: absolute_path(head_path), + }, + ) + .await + .expect("first watch should succeed"); + + let error = manager + .watch( + ConnectionId(1), + FsWatchParams { + watch_id: "watch-head".to_string(), + path: absolute_path(fetch_head_path), + }, + ) + .await + .expect_err("duplicate watch should fail"); + + assert_eq!(error.message, "watchId already exists: watch-head"); + assert_eq!(manager.state.lock().await.entries.len(), 1); + } + #[tokio::test] async fn connection_closed_removes_only_that_connections_watches() { let temp_dir = TempDir::new().expect("temp dir"); @@ -330,28 +363,31 @@ mod tests { std::fs::write(&packed_refs_path, "refs\n").expect("write packed-refs"); let manager = manager_with_noop_watcher(); - let response_1 = manager + let response = manager .watch( ConnectionId(1), FsWatchParams { - path: absolute_path(head_path), + watch_id: "watch-head".to_string(), + path: absolute_path(head_path.clone()), }, ) .await .expect("first watch should succeed"); - let response_2 = manager + manager .watch( ConnectionId(1), FsWatchParams { + watch_id: "watch-fetch-head".to_string(), path: absolute_path(fetch_head_path), }, ) .await .expect("second watch should succeed"); - let response_3 = manager + manager .watch( ConnectionId(2), FsWatchParams { + watch_id: "watch-packed-refs".to_string(), path: absolute_path(packed_refs_path), }, ) @@ -371,9 +407,9 @@ mod tests { .collect::>(), HashSet::from([WatchKey { connection_id: ConnectionId(2), - watch_id: response_3.watch_id, + watch_id: "watch-packed-refs".to_string(), }]) ); - assert_ne!(response_1.watch_id, response_2.watch_id); + assert_eq!(response.path, absolute_path(head_path)); } } diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index a1479f31b0..14e5d00028 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -64,6 +64,7 @@ use crate::outgoing_message::QueuedOutgoingMessage; use crate::transport::CHANNEL_CAPACITY; use crate::transport::OutboundConnectionState; use crate::transport::route_outgoing_envelope; +use codex_analytics::AppServerRpcTransport; use codex_app_server_protocol::ClientNotification; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigWarningNotification; @@ -77,7 +78,9 @@ use codex_arg0::Arg0DispatchPaths; 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; use codex_protocol::protocol::SessionSource; use tokio::sync::mpsc; use tokio::sync::oneshot; @@ -377,12 +380,15 @@ 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); let (processor_tx, mut processor_rx) = mpsc::channel::(channel_capacity); let mut processor_handle = tokio::spawn(async move { let mut processor = MessageProcessor::new(MessageProcessorArgs { outgoing: Arc::clone(&processor_outgoing), arg0_paths: args.arg0_paths, config: args.config, + environment_manager: Arc::new(EnvironmentManager::from_env()), cli_overrides: args.cli_overrides, loader_overrides: args.loader_overrides, cloud_requirements: args.cloud_requirements, @@ -390,7 +396,9 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { log_db: None, config_warnings: args.config_warnings, session_source: args.session_source, - enable_codex_api_key_env: args.enable_codex_api_key_env, + auth_manager, + rpc_transport: AppServerRpcTransport::InProcess, + remote_control_handle: None, }); let mut thread_created_rx = processor.thread_created_receiver(); let mut session = ConnectionSessionState::default(); @@ -785,7 +793,8 @@ mod tests { #[tokio::test] async fn in_process_start_clamps_zero_channel_capacity() { - let client = start_test_client_with_capacity(SessionSource::Cli, 0).await; + let client = + start_test_client_with_capacity(SessionSource::Cli, /*channel_capacity*/ 0).await; let response = loop { match client .request(ClientRequest::ConfigRequirementsRead { @@ -819,6 +828,9 @@ mod tests { items: Vec::new(), status: TurnStatus::Completed, error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, }, }) )); diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index e4db5d7ca1..2f8188d6c1 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -2,12 +2,13 @@ use codex_arg0::Arg0DispatchPaths; use codex_cloud_requirements::cloud_requirements_loader; -use codex_core::AuthManager; use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::ConfigLayerStackOrdering; use codex_core::config_loader::LoaderOverrides; +use codex_features::Feature; +use codex_login::AuthManager; use codex_utils_cli::CliConfigOverrides; use std::collections::HashMap; use std::collections::HashSet; @@ -29,8 +30,10 @@ use crate::transport::OutboundConnectionState; use crate::transport::TransportEvent; use crate::transport::auth::policy_from_settings; use crate::transport::route_outgoing_envelope; +use crate::transport::start_remote_control; use crate::transport::start_stdio_connection; use crate::transport::start_websocket_acceptor; +use codex_analytics::AppServerRpcTransport; use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCMessage; @@ -40,10 +43,12 @@ use codex_core::ExecPolicyError; use codex_core::check_execpolicy_for_warnings; use codex_core::config_loader::ConfigLoadError; use codex_core::config_loader::TextRange as CoreTextRange; +use codex_exec_server::EnvironmentManager; use codex_feedback::CodexFeedback; use codex_protocol::protocol::SessionSource; use codex_state::log_db; use tokio::sync::mpsc; +use tokio::sync::oneshot; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use toml::Value as TomlValue; @@ -355,44 +360,13 @@ pub async fn run_main_with_transport( session_source: SessionSource, auth: AppServerWebsocketAuthSettings, ) -> IoResult<()> { + let environment_manager = Arc::new(EnvironmentManager::from_env()); let (transport_event_tx, mut transport_event_rx) = mpsc::channel::(CHANNEL_CAPACITY); let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(CHANNEL_CAPACITY); let (outbound_control_tx, mut outbound_control_rx) = mpsc::channel::(CHANNEL_CAPACITY); - enum TransportRuntime { - Stdio, - WebSocket { - accept_handle: JoinHandle<()>, - shutdown_token: CancellationToken, - }, - } - - let mut stdio_handles = Vec::>::new(); - let transport_runtime = match transport { - AppServerTransport::Stdio => { - start_stdio_connection(transport_event_tx.clone(), &mut stdio_handles).await?; - TransportRuntime::Stdio - } - AppServerTransport::WebSocket { bind_address } => { - let shutdown_token = CancellationToken::new(); - let accept_handle = start_websocket_acceptor( - bind_address, - transport_event_tx.clone(), - shutdown_token.clone(), - policy_from_settings(&auth)?, - ) - .await?; - TransportRuntime::WebSocket { - accept_handle, - shutdown_token, - } - } - }; - let single_client_mode = matches!(&transport_runtime, TransportRuntime::Stdio); - let shutdown_when_no_connections = single_client_mode; - let graceful_signal_restart_enabled = !single_client_mode; // Parse CLI overrides once and derive the base Config eagerly so later // components do not need to work with raw TOML values. let cli_kv_overrides = cli_config_overrides.parse_overrides().map_err(|e| { @@ -425,11 +399,8 @@ pub async fn run_main_with_transport( } } - let auth_manager = AuthManager::shared( - config.codex_home.clone(), - /*enable_codex_api_key_env*/ false, - config.cli_auth_credentials_store_mode, - ); + let auth_manager = + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false); cloud_requirements_loader( auth_manager, config.chatgpt_base_url, @@ -488,7 +459,9 @@ pub async fn run_main_with_transport( range: None, }); } - if let Some(warning) = codex_core::config::system_bwrap_warning() { + if let Some(warning) = + codex_core::config::system_bwrap_warning(config.permissions.sandbox_policy.get()) + { config_warnings.push(ConfigWarningNotification { summary: warning, details: None, @@ -531,13 +504,13 @@ pub async fn run_main_with_transport( let feedback_layer = feedback.logger_layer(); let feedback_metadata_layer = feedback.metadata_layer(); - let log_db = codex_state::StateRuntime::init( + let state_db = codex_state::StateRuntime::init( config.sqlite_home.clone(), config.model_provider_id.clone(), ) .await - .ok() - .map(log_db::start); + .ok(); + let log_db = state_db.clone().map(log_db::start); let log_db_layer = log_db .clone() .map(|layer| layer.with_filter(Targets::new().with_default(Level::TRACE))); @@ -558,6 +531,61 @@ pub async fn run_main_with_transport( } } + let transport_shutdown_token = CancellationToken::new(); + let mut transport_accept_handles = Vec::>::new(); + + let single_client_mode = matches!(&transport, AppServerTransport::Stdio); + let shutdown_when_no_connections = single_client_mode; + let graceful_signal_restart_enabled = !single_client_mode; + let mut app_server_client_name_rx = None; + + match transport { + AppServerTransport::Stdio => { + let (stdio_client_name_tx, stdio_client_name_rx) = oneshot::channel::(); + app_server_client_name_rx = Some(stdio_client_name_rx); + start_stdio_connection( + transport_event_tx.clone(), + &mut transport_accept_handles, + stdio_client_name_tx, + ) + .await?; + } + AppServerTransport::WebSocket { bind_address } => { + let accept_handle = start_websocket_acceptor( + bind_address, + transport_event_tx.clone(), + transport_shutdown_token.clone(), + policy_from_settings(&auth)?, + ) + .await?; + transport_accept_handles.push(accept_handle); + } + AppServerTransport::Off => {} + } + + let auth_manager = + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false); + + let remote_control_enabled = config.features.enabled(Feature::RemoteControl); + if transport_accept_handles.is_empty() && !remote_control_enabled { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + "no transport configured; use --listen or enable remote control", + )); + } + + let (remote_control_accept_handle, remote_control_handle) = start_remote_control( + config.chatgpt_base_url.clone(), + state_db.clone(), + auth_manager.clone(), + transport_event_tx.clone(), + transport_shutdown_token.clone(), + app_server_client_name_rx, + remote_control_enabled, + ) + .await?; + transport_accept_handles.push(remote_control_accept_handle); + let outbound_handle = tokio::spawn(async move { let mut outbound_connections = HashMap::::new(); loop { @@ -616,12 +644,15 @@ pub async fn run_main_with_transport( let processor_handle = tokio::spawn({ 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); let cli_overrides: Vec<(String, TomlValue)> = cli_kv_overrides.clone(); let loader_overrides = loader_overrides_for_config_api; let mut processor = MessageProcessor::new(MessageProcessorArgs { outgoing: outgoing_message_sender, arg0_paths, config: Arc::new(config), + environment_manager, cli_overrides, loader_overrides, cloud_requirements: cloud_requirements.clone(), @@ -629,15 +660,14 @@ pub async fn run_main_with_transport( log_db, config_warnings, session_source, - enable_codex_api_key_env: false, + auth_manager, + rpc_transport: analytics_rpc_transport(transport), + remote_control_handle: Some(remote_control_handle), }); let mut thread_created_rx = processor.thread_created_receiver(); let mut running_turn_count_rx = processor.subscribe_running_assistant_turn_count(); let mut connections = HashMap::::new(); - let websocket_accept_shutdown = match &transport_runtime { - TransportRuntime::WebSocket { shutdown_token, .. } => Some(shutdown_token.clone()), - TransportRuntime::Stdio => None, - }; + let transport_shutdown_token = transport_shutdown_token.clone(); async move { let mut listen_for_threads = true; let mut shutdown_state = ShutdownState::default(); @@ -650,9 +680,7 @@ pub async fn run_main_with_transport( shutdown_state.update(running_turn_count, connections.len()), ShutdownAction::Finish ) { - if let Some(shutdown_token) = &websocket_accept_shutdown { - shutdown_token.cancel(); - } + transport_shutdown_token.cancel(); let _ = outbound_control_tx .send(OutboundControlEvent::DisconnectAll) .await; @@ -846,16 +874,8 @@ pub async fn run_main_with_transport( let _ = processor_handle.await; let _ = outbound_handle.await; - if let TransportRuntime::WebSocket { - accept_handle, - shutdown_token, - } = transport_runtime - { - shutdown_token.cancel(); - let _ = accept_handle.await; - } - - for handle in stdio_handles { + transport_shutdown_token.cancel(); + for handle in transport_accept_handles { let _ = handle.await; } @@ -866,6 +886,15 @@ pub async fn run_main_with_transport( Ok(()) } +fn analytics_rpc_transport(transport: AppServerTransport) -> AppServerRpcTransport { + match transport { + AppServerTransport::Stdio => AppServerRpcTransport::Stdio, + AppServerTransport::WebSocket { .. } | AppServerTransport::Off => { + AppServerRpcTransport::Websocket + } + } +} + #[cfg(test)] mod tests { use super::LogFormat; @@ -880,7 +909,10 @@ mod tests { #[test] fn log_format_from_env_value_defaults_for_non_json_values() { - assert_eq!(LogFormat::from_env_value(None), LogFormat::Default); + assert_eq!( + LogFormat::from_env_value(/*value*/ None), + LogFormat::Default + ); assert_eq!(LogFormat::from_env_value(Some("")), LogFormat::Default); assert_eq!(LogFormat::from_env_value(Some("text")), LogFormat::Default); assert_eq!(LogFormat::from_env_value(Some("jsonl")), LogFormat::Default); diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index fa95f973ea..9a23680fb9 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -16,7 +16,7 @@ const MANAGED_CONFIG_PATH_ENV_VAR: &str = "CODEX_APP_SERVER_MANAGED_CONFIG_PATH" #[derive(Debug, Parser)] struct AppServerArgs { /// Transport endpoint URL. Supported values: `stdio://` (default), - /// `ws://IP:PORT`. + /// `ws://IP:PORT`, `off`. #[arg( long = "listen", value_name = "URL", diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index feca709513..fbc1bd6d10 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -19,7 +19,12 @@ use crate::outgoing_message::ConnectionRequestId; use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::RequestContext; use crate::transport::AppServerTransport; +use crate::transport::RemoteControlHandle; use async_trait::async_trait; +use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AppServerRpcTransport; +use codex_app_server_protocol::AppListUpdatedNotification; +use codex_app_server_protocol::AuthMode as LoginAuthMode; use codex_app_server_protocol::ChatgptAuthTokensRefreshParams; use codex_app_server_protocol::ChatgptAuthTokensRefreshReason; use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; @@ -53,25 +58,25 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::experimental_required_message; use codex_arg0::Arg0DispatchPaths; -use codex_core::AnalyticsEventsClient; -use codex_core::AuthManager; +use codex_chatgpt::connectors; use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::LoaderOverrides; -use codex_core::default_client::DEFAULT_ORIGINATOR; -use codex_core::default_client::SetOriginatorError; -use codex_core::default_client::USER_AGENT_SUFFIX; -use codex_core::default_client::get_codex_user_agent; -use codex_core::default_client::set_default_client_residency_requirement; -use codex_core::default_client::set_default_originator; -use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; +use codex_exec_server::EnvironmentManager; use codex_features::Feature; use codex_feedback::CodexFeedback; +use codex_login::AuthManager; +use codex_login::auth::ExternalAuth; use codex_login::auth::ExternalAuthRefreshContext; use codex_login::auth::ExternalAuthRefreshReason; -use codex_login::auth::ExternalAuthRefresher; use codex_login::auth::ExternalAuthTokens; +use codex_login::default_client::SetOriginatorError; +use codex_login::default_client::USER_AGENT_SUFFIX; +use codex_login::default_client::get_codex_user_agent; +use codex_login::default_client::set_default_client_residency_requirement; +use codex_login::default_client::set_default_originator; +use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_protocol::ThreadId; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::W3cTraceContext; @@ -85,7 +90,6 @@ use toml::Value as TomlValue; use tracing::Instrument; const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); -const TUI_APP_SERVER_CLIENT_NAME: &str = "codex-tui"; #[derive(Clone)] struct ExternalAuthRefreshBridge { @@ -101,7 +105,11 @@ impl ExternalAuthRefreshBridge { } #[async_trait] -impl ExternalAuthRefresher for ExternalAuthRefreshBridge { +impl ExternalAuth for ExternalAuthRefreshBridge { + fn auth_mode(&self) -> LoginAuthMode { + LoginAuthMode::Chatgpt + } + async fn refresh( &self, context: ExternalAuthRefreshContext, @@ -143,11 +151,11 @@ impl ExternalAuthRefresher for ExternalAuthRefreshBridge { let response: ChatgptAuthTokensRefreshResponse = serde_json::from_value(result).map_err(std::io::Error::other)?; - Ok(ExternalAuthTokens { - access_token: response.access_token, - chatgpt_account_id: response.chatgpt_account_id, - chatgpt_plan_type: response.chatgpt_plan_type, - }) + Ok(ExternalAuthTokens::chatgpt( + response.access_token, + response.chatgpt_account_id, + response.chatgpt_plan_type, + )) } } @@ -158,9 +166,12 @@ pub(crate) struct MessageProcessor { external_agent_config_api: ExternalAgentConfigApi, fs_api: FsApi, auth_manager: Arc, + analytics_events_client: AnalyticsEventsClient, fs_watch_manager: FsWatchManager, config: Arc, config_warnings: Arc>, + rpc_transport: AppServerRpcTransport, + remote_control_handle: Option, } #[derive(Clone, Debug, Default)] @@ -176,6 +187,7 @@ pub(crate) struct MessageProcessorArgs { pub(crate) outgoing: Arc, pub(crate) arg0_paths: Arg0DispatchPaths, pub(crate) config: Arc, + pub(crate) environment_manager: Arc, pub(crate) cli_overrides: Vec<(String, TomlValue)>, pub(crate) loader_overrides: LoaderOverrides, pub(crate) cloud_requirements: CloudRequirementsLoader, @@ -183,7 +195,9 @@ pub(crate) struct MessageProcessorArgs { pub(crate) log_db: Option, pub(crate) config_warnings: Vec, pub(crate) session_source: SessionSource, - pub(crate) enable_codex_api_key_env: bool, + pub(crate) auth_manager: Arc, + pub(crate) rpc_transport: AppServerRpcTransport, + pub(crate) remote_control_handle: Option, } impl MessageProcessor { @@ -194,6 +208,7 @@ impl MessageProcessor { outgoing, arg0_paths, config, + environment_manager, cli_overrides, loader_overrides, cloud_requirements, @@ -201,13 +216,13 @@ impl MessageProcessor { log_db, config_warnings, session_source, - enable_codex_api_key_env, + auth_manager, + rpc_transport, + remote_control_handle, } = args; - let auth_manager = AuthManager::shared( - config.codex_home.clone(), - enable_codex_api_key_env, - config.cli_auth_credentials_store_mode, - ); + auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { + outgoing: outgoing.clone(), + })); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), auth_manager.clone(), @@ -217,11 +232,8 @@ impl MessageProcessor { .features .enabled(Feature::DefaultModeRequestUserInput), }, + environment_manager, )); - auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - auth_manager.set_external_auth_refresher(Arc::new(ExternalAuthRefreshBridge { - outgoing: outgoing.clone(), - })); let analytics_events_client = AnalyticsEventsClient::new( Arc::clone(&auth_manager), config.chatgpt_base_url.trim_end_matches('/').to_string(), @@ -238,6 +250,7 @@ impl MessageProcessor { auth_manager: auth_manager.clone(), thread_manager: Arc::clone(&thread_manager), outgoing: outgoing.clone(), + analytics_events_client: analytics_events_client.clone(), arg0_paths, config: Arc::clone(&config), cli_overrides: cli_overrides.clone(), @@ -258,7 +271,7 @@ impl MessageProcessor { loader_overrides, cloud_requirements, thread_manager, - analytics_events_client, + analytics_events_client.clone(), ); let external_agent_config_api = ExternalAgentConfigApi::new(config.codex_home.clone()); let fs_api = FsApi::default(); @@ -271,14 +284,17 @@ impl MessageProcessor { external_agent_config_api, fs_api, auth_manager, + analytics_events_client, fs_watch_manager, config, config_warnings: Arc::new(config_warnings), + rpc_transport, + remote_control_handle, } } pub(crate) fn clear_runtime_references(&self) { - self.auth_manager.clear_external_auth_refresher(); + self.auth_manager.clear_external_auth(); } pub(crate) async fn process_request( @@ -543,6 +559,7 @@ impl MessageProcessor { // shared thread when another connected client did not opt into // experimental API). Proposed direction is instance-global first-write-wins // with initialize-time mismatch rejection. + let analytics_initialize_params = params.clone(); let (experimental_api_enabled, opt_out_notification_methods) = match params.capabilities { Some(capabilities) => ( @@ -563,14 +580,8 @@ impl MessageProcessor { } = params.client_info; session.app_server_client_name = Some(name.clone()); session.client_version = Some(version.clone()); - let originator = if name == TUI_APP_SERVER_CLIENT_NAME { - // TODO: Remove this temporary workaround once app-server clients no longer - // need to retain the legacy TUI `codex_cli_rs` originator behavior. - DEFAULT_ORIGINATOR.to_string() - } else { - name.clone() - }; - if let Err(error) = set_default_originator(originator) { + let originator = name.clone(); + if let Err(error) = set_default_originator(originator.clone()) { match error { SetOriginatorError::InvalidHeaderValue => { let error = JSONRPCErrorError { @@ -593,6 +604,14 @@ impl MessageProcessor { } } } + if self.config.features.enabled(Feature::GeneralAnalytics) { + self.analytics_events_client.track_initialize( + connection_id.0, + analytics_initialize_params, + originator, + self.rpc_transport, + ); + } set_default_client_residency_requirement(self.config.enforce_residency.value()); let user_agent_suffix = format!("{name}; {version}"); if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() { @@ -830,6 +849,7 @@ impl MessageProcessor { connection_id, other, session.app_server_client_name.clone(), + session.client_version.clone(), request_context, ) .boxed() @@ -850,16 +870,8 @@ impl MessageProcessor { request_id: ConnectionRequestId, params: ConfigValueWriteParams, ) { - match self.config_api.write_value(params).await { - Ok(response) => { - self.codex_message_processor.clear_plugin_related_caches(); - self.codex_message_processor - .maybe_start_plugin_startup_tasks_for_latest_config() - .await; - self.outgoing.send_response(request_id, response).await; - } - Err(error) => self.outgoing.send_error(request_id, error).await, - } + let result = self.config_api.write_value(params).await; + self.handle_config_mutation_result(request_id, result).await } async fn handle_config_batch_write( @@ -867,8 +879,8 @@ impl MessageProcessor { request_id: ConnectionRequestId, params: ConfigBatchWriteParams, ) { - self.handle_config_mutation_result(request_id, self.config_api.batch_write(params).await) - .await; + let result = self.config_api.batch_write(params).await; + self.handle_config_mutation_result(request_id, result).await; } async fn handle_experimental_feature_enablement_set( @@ -876,13 +888,83 @@ impl MessageProcessor { request_id: ConnectionRequestId, params: ExperimentalFeatureEnablementSetParams, ) { - self.handle_config_mutation_result( - request_id, - self.config_api - .set_experimental_feature_enablement(params) - .await, - ) - .await; + let should_refresh_apps_list = params.enablement.get("apps").copied() == Some(true); + let result = self + .config_api + .set_experimental_feature_enablement(params) + .await; + let is_ok = result.is_ok(); + self.handle_config_mutation_result(request_id, result).await; + if should_refresh_apps_list && is_ok { + self.refresh_apps_list_after_experimental_feature_enablement_set() + .await; + } + } + + async fn refresh_apps_list_after_experimental_feature_enablement_set(&self) { + let config = match self + .config_api + .load_latest_config(/*fallback_cwd*/ None) + .await + { + Ok(config) => config, + Err(error) => { + tracing::warn!( + "failed to load config for apps list refresh after experimental feature enablement: {}", + error.message + ); + return; + } + }; + let auth = self.auth_manager.auth().await; + if !config.features.apps_enabled_for_auth( + auth.as_ref() + .is_some_and(codex_login::CodexAuth::is_chatgpt_auth), + ) { + return; + } + + let outgoing = Arc::clone(&self.outgoing); + tokio::spawn(async move { + let (all_connectors_result, accessible_connectors_result) = tokio::join!( + connectors::list_all_connectors_with_options(&config, /*force_refetch*/ true), + connectors::list_accessible_connectors_from_mcp_tools_with_options( + &config, /*force_refetch*/ true, + ), + ); + let all_connectors = match all_connectors_result { + Ok(connectors) => connectors, + Err(err) => { + tracing::warn!( + "failed to force-refresh directory apps after experimental feature enablement: {err:#}" + ); + return; + } + }; + let accessible_connectors = match accessible_connectors_result { + Ok(connectors) => connectors, + Err(err) => { + tracing::warn!( + "failed to force-refresh accessible apps after experimental feature enablement: {err:#}" + ); + return; + } + }; + + let data = connectors::with_app_enabled_state( + connectors::merge_connectors_with_accessible( + all_connectors, + accessible_connectors, + /*all_connectors_loaded*/ true, + ), + &config, + ); + outgoing + .send_server_notification(ServerNotification::AppListUpdated( + AppListUpdatedNotification { data }, + )) + .await; + }); } async fn handle_config_mutation_result( @@ -892,16 +974,36 @@ impl MessageProcessor { ) { match result { Ok(response) => { - self.codex_message_processor.clear_plugin_related_caches(); - self.codex_message_processor - .maybe_start_plugin_startup_tasks_for_latest_config() - .await; + self.handle_config_mutation().await; self.outgoing.send_response(request_id, response).await; } Err(error) => self.outgoing.send_error(request_id, error).await, } } + async fn handle_config_mutation(&self) { + self.codex_message_processor.handle_config_mutation(); + let Some(remote_control_handle) = &self.remote_control_handle else { + return; + }; + + match self + .config_api + .load_latest_config(/*fallback_cwd*/ None) + .await + { + Ok(config) => { + remote_control_handle.set_enabled(config.features.enabled(Feature::RemoteControl)); + } + Err(error) => { + tracing::warn!( + "failed to load config for remote control enablement refresh after config mutation: {}", + error.message + ); + } + } + } + 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, 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 aa66f79f29..ef88364a3a 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -7,6 +7,7 @@ use crate::transport::AppServerTransport; use anyhow::Result; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::write_mock_responses_config_toml; +use codex_analytics::AppServerRpcTransport; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::InitializeCapabilities; @@ -24,7 +25,9 @@ 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; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::W3cTraceContext; use opentelemetry::global; @@ -146,7 +149,7 @@ impl TracingHarness { }), }, }, - None, + /*trace*/ None, ) .await; assert!(harness.session.initialized); @@ -212,7 +215,7 @@ async fn build_test_config(codex_home: &Path, server_uri: &str) -> Result .. } = RemoteTrace::new("00000000000000000000000000000011", "0000000000000022"); - let _: ThreadStartResponse = harness.start_thread(20_002, None).await; + let _: ThreadStartResponse = harness + .start_thread(/*request_id*/ 20_002, /*trace*/ None) + .await; let untraced_spans = wait_for_exported_spans(harness.tracing, |spans| { spans.iter().any(|span| { span.span_kind == SpanKind::Server @@ -536,10 +546,16 @@ async fn thread_start_jsonrpc_span_exports_server_span_and_parents_children() -> .span_context .trace_id(), ); - assert_has_internal_descendant_at_min_depth(&untraced_spans, untraced_server_span, 1); + assert_has_internal_descendant_at_min_depth( + &untraced_spans, + untraced_server_span, + /*min_depth*/ 1, + ); let baseline_len = untraced_spans.len(); - let _: ThreadStartResponse = harness.start_thread(20_003, Some(remote_trace)).await; + let _: ThreadStartResponse = harness + .start_thread(/*request_id*/ 20_003, Some(remote_trace)) + .await; let spans = wait_for_new_exported_spans(harness.tracing, baseline_len, |spans| { spans.iter().any(|span| { span.span_kind == SpanKind::Server @@ -559,8 +575,8 @@ async fn thread_start_jsonrpc_span_exports_server_span_and_parents_children() -> assert!(server_request_span.parent_span_is_remote); assert_eq!(server_request_span.span_context.trace_id(), remote_trace_id); assert_ne!(server_request_span.span_context.span_id(), SpanId::INVALID); - assert_has_internal_descendant_at_min_depth(&spans, server_request_span, 1); - assert_has_internal_descendant_at_min_depth(&spans, server_request_span, 2); + assert_has_internal_descendant_at_min_depth(&spans, server_request_span, /*min_depth*/ 1); + assert_has_internal_descendant_at_min_depth(&spans, server_request_span, /*min_depth*/ 2); harness.shutdown().await; Ok(()) @@ -570,7 +586,7 @@ async fn thread_start_jsonrpc_span_exports_server_span_and_parents_children() -> async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { let _guard = tracing_test_guard().lock().await; let mut harness = TracingHarness::new().await?; - let thread_start_response = harness.start_thread(2, None).await; + let thread_start_response = harness.start_thread(/*request_id*/ 2, /*trace*/ None).await; let thread_id = thread_start_response.thread.id.clone(); harness.reset_tracing(); diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index 6dbe77455f..3466678a6f 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -4,7 +4,7 @@ use codex_app_server_protocol::Model; use codex_app_server_protocol::ModelUpgradeInfo; use codex_app_server_protocol::ReasoningEffortOption; use codex_core::ThreadManager; -use codex_core::models_manager::manager::RefreshStrategy; +use codex_models_manager::manager::RefreshStrategy; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffortPreset; diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 946a819d79..d4e4bde063 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -886,7 +886,7 @@ mod tests { .register_request_context(RequestContext::new( request_id.clone(), tracing::info_span!("app_server.request", rpc.method = "thread/start"), - None, + /*parent_trace*/ None, )) .await; assert_eq!(outgoing.request_context_count().await, 1); @@ -997,14 +997,14 @@ mod tests { .register_request_context(RequestContext::new( closed_connection_request, tracing::info_span!("app_server.request", rpc.method = "turn/interrupt"), - None, + /*parent_trace*/ None, )) .await; outgoing .register_request_context(RequestContext::new( open_connection_request, tracing::info_span!("app_server.request", rpc.method = "turn/start"), - None, + /*parent_trace*/ None, )) .await; assert_eq!(outgoing.request_context_count().await, 2); diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index be5478dd51..0fe835fc75 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -16,6 +16,7 @@ use std::sync::Weak; use tokio::sync::Mutex; use tokio::sync::mpsc; use tokio::sync::oneshot; +use tracing::error; type PendingInterruptQueue = Vec<( ConnectionRequestId, @@ -44,6 +45,7 @@ pub(crate) enum ThreadListenerCommand { /// Per-conversation accumulation of the latest states e.g. error message while a turn runs. #[derive(Default, Clone)] pub(crate) struct TurnSummary { + pub(crate) started_at: Option, pub(crate) file_change_started: HashSet, pub(crate) command_execution_started: HashSet, pub(crate) last_error: Option, @@ -109,13 +111,50 @@ impl ThreadState { } pub(crate) fn track_current_turn_event(&mut self, event: &EventMsg) { + if let EventMsg::TurnStarted(payload) = event { + self.turn_summary.started_at = payload.started_at; + } self.current_turn_history.handle_event(event); - if !self.current_turn_history.has_active_turn() { + if matches!(event, EventMsg::TurnAborted(_) | EventMsg::TurnComplete(_)) + && !self.current_turn_history.has_active_turn() + { self.current_turn_history.reset(); } } } +pub(crate) async fn resolve_server_request_on_thread_listener( + thread_state: &Arc>, + request_id: RequestId, +) { + let (completion_tx, completion_rx) = oneshot::channel(); + let listener_command_tx = { + let state = thread_state.lock().await; + state.listener_command_tx() + }; + let Some(listener_command_tx) = listener_command_tx else { + error!("failed to remove pending client request: thread listener is not running"); + return; + }; + + if listener_command_tx + .send(ThreadListenerCommand::ResolveServerRequest { + request_id, + completion_tx, + }) + .is_err() + { + error!( + "failed to remove pending client request: thread listener command channel is closed" + ); + return; + } + + if let Err(err) = completion_rx.await { + error!("failed to remove pending client request: {err}"); + } +} + struct ThreadEntry { state: Arc>, connection_ids: HashSet, diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index f4728616db..802f7e197c 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -530,7 +530,7 @@ mod tests { #[test] fn resolves_in_progress_turn_to_active_status() { - let status = resolve_thread_status(ThreadStatus::Idle, true); + let status = resolve_thread_status(ThreadStatus::Idle, /*has_in_progress_turn*/ true); assert_eq!( status, ThreadStatus::Active { @@ -538,7 +538,8 @@ mod tests { } ); - let status = resolve_thread_status(ThreadStatus::NotLoaded, true); + let status = + resolve_thread_status(ThreadStatus::NotLoaded, /*has_in_progress_turn*/ true); assert_eq!( status, ThreadStatus::Active { @@ -550,11 +551,14 @@ mod tests { #[test] fn keeps_status_when_no_in_progress_turn() { assert_eq!( - resolve_thread_status(ThreadStatus::Idle, false), + resolve_thread_status(ThreadStatus::Idle, /*has_in_progress_turn*/ false), ThreadStatus::Idle ); assert_eq!( - resolve_thread_status(ThreadStatus::SystemError, false), + resolve_thread_status( + ThreadStatus::SystemError, + /*has_in_progress_turn*/ false + ), ThreadStatus::SystemError ); } @@ -788,6 +792,7 @@ mod tests { fn test_thread(thread_id: &str, source: codex_app_server_protocol::SessionSource) -> Thread { Thread { id: thread_id.to_string(), + forked_from_id: None, preview: String::new(), ephemeral: false, model_provider: "mock-provider".to_string(), diff --git a/codex-rs/app-server/src/transport/auth.rs b/codex-rs/app-server/src/transport/auth.rs index 1c6080e748..a67c692b7d 100644 --- a/codex-rs/app-server/src/transport/auth.rs +++ b/codex-rs/app-server/src/transport/auth.rs @@ -506,8 +506,14 @@ mod tests { }), ); let tampered = token.replace(".eyJleHAi", ".eyJleHBi"); - let err = verify_signed_bearer_token(&tampered, shared_secret, None, None, 30) - .expect_err("tampered jwt should fail"); + let err = verify_signed_bearer_token( + &tampered, + shared_secret, + /*issuer*/ None, + /*audience*/ None, + /*max_clock_skew_seconds*/ 30, + ) + .expect_err("tampered jwt should fail"); assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED); } @@ -522,8 +528,14 @@ mod tests { "aud": "audience", }), ); - verify_signed_bearer_token(&token, shared_secret, Some("issuer"), Some("audience"), 30) - .expect("valid signed token should verify"); + verify_signed_bearer_token( + &token, + shared_secret, + Some("issuer"), + Some("audience"), + /*max_clock_skew_seconds*/ 30, + ) + .expect("valid signed token should verify"); } #[test] @@ -536,8 +548,14 @@ mod tests { "aud": ["other-audience", "audience"], }), ); - verify_signed_bearer_token(&token, shared_secret, None, Some("audience"), 30) - .expect("jwt audience arrays should verify"); + verify_signed_bearer_token( + &token, + shared_secret, + /*issuer*/ None, + Some("audience"), + /*max_clock_skew_seconds*/ 30, + ) + .expect("jwt audience arrays should verify"); } #[test] @@ -550,9 +568,14 @@ mod tests { ); let header_segment = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#); let token = format!("{header_segment}.{claims_segment}."); - let err = - verify_signed_bearer_token(&token, b"0123456789abcdef0123456789abcdef", None, None, 30) - .expect_err("alg=none jwt should be rejected"); + let err = verify_signed_bearer_token( + &token, + b"0123456789abcdef0123456789abcdef", + /*issuer*/ None, + /*audience*/ None, + /*max_clock_skew_seconds*/ 30, + ) + .expect_err("alg=none jwt should be rejected"); assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED); } @@ -565,8 +588,14 @@ mod tests { "iss": "issuer", }), ); - let err = verify_signed_bearer_token(&token, shared_secret, None, None, 30) - .expect_err("jwt without exp should be rejected"); + let err = verify_signed_bearer_token( + &token, + shared_secret, + /*issuer*/ None, + /*audience*/ None, + /*max_clock_skew_seconds*/ 30, + ) + .expect_err("jwt without exp should be rejected"); assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED); } diff --git a/codex-rs/app-server/src/transport.rs b/codex-rs/app-server/src/transport/mod.rs similarity index 66% rename from codex-rs/app-server/src/transport.rs rename to codex-rs/app-server/src/transport/mod.rs index 020e441da5..92383cb78f 100644 --- a/codex-rs/app-server/src/transport.rs +++ b/codex-rs/app-server/src/transport/mod.rs @@ -1,8 +1,5 @@ pub(crate) mod auth; -use self::auth::WebsocketAuthPolicy; -use self::auth::authorize_upgrade; -use self::auth::should_warn_about_unauthenticated_non_loopback_listener; use crate::error_code::OVERLOADED_ERROR_CODE; use crate::message_processor::ConnectionSessionState; use crate::outgoing_message::ConnectionId; @@ -10,35 +7,11 @@ use crate::outgoing_message::OutgoingEnvelope; use crate::outgoing_message::OutgoingError; use crate::outgoing_message::OutgoingMessage; use crate::outgoing_message::QueuedOutgoingMessage; -use axum::Router; -use axum::body::Body; -use axum::extract::ConnectInfo; -use axum::extract::State; -use axum::extract::ws::Message as WebSocketMessage; -use axum::extract::ws::WebSocket; -use axum::extract::ws::WebSocketUpgrade; -use axum::http::HeaderMap; -use axum::http::Request; -use axum::http::StatusCode; -use axum::http::header::ORIGIN; -use axum::middleware; -use axum::middleware::Next; -use axum::response::IntoResponse; -use axum::response::Response; -use axum::routing::any; -use axum::routing::get; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::ServerRequest; -use futures::SinkExt; -use futures::StreamExt; -use owo_colors::OwoColorize; -use owo_colors::Stream; -use owo_colors::Style; use std::collections::HashMap; use std::collections::HashSet; -use std::io::ErrorKind; -use std::io::Result as IoResult; use std::net::SocketAddr; use std::str::FromStr; use std::sync::Arc; @@ -46,17 +19,9 @@ use std::sync::RwLock; use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; -use tokio::io::AsyncBufReadExt; -use tokio::io::AsyncWriteExt; -use tokio::io::BufReader; -use tokio::io::{self}; -use tokio::net::TcpListener; use tokio::sync::mpsc; -use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; -use tracing::debug; use tracing::error; -use tracing::info; use tracing::warn; /// Size of the bounded channels used to communicate between tasks. The value @@ -64,90 +29,20 @@ use tracing::warn; /// plenty for an interactive CLI. pub(crate) const CHANNEL_CAPACITY: usize = 128; -fn colorize(text: &str, style: Style) -> String { - text.if_supports_color(Stream::Stderr, |value| value.style(style)) - .to_string() -} +mod remote_control; +mod stdio; +mod websocket; -#[allow(clippy::print_stderr)] -fn print_websocket_startup_banner(addr: SocketAddr) { - let title = colorize("codex app-server (WebSockets)", Style::new().bold().cyan()); - let listening_label = colorize("listening on:", Style::new().dimmed()); - let listen_url = colorize(&format!("ws://{addr}"), Style::new().green()); - let ready_label = colorize("readyz:", Style::new().dimmed()); - let ready_url = colorize(&format!("http://{addr}/readyz"), Style::new().green()); - let health_label = colorize("healthz:", Style::new().dimmed()); - let health_url = colorize(&format!("http://{addr}/healthz"), Style::new().green()); - let note_label = colorize("note:", Style::new().dimmed()); - eprintln!("{title}"); - eprintln!(" {listening_label} {listen_url}"); - eprintln!(" {ready_label} {ready_url}"); - eprintln!(" {health_label} {health_url}"); - if addr.ip().is_loopback() { - eprintln!( - " {note_label} binds localhost only (use SSH port-forwarding for remote access)" - ); - } else { - eprintln!( - " {note_label} websocket auth is opt-in in this build; configure `--ws-auth ...` before real remote use" - ); - } -} - -#[derive(Clone)] -struct WebSocketListenerState { - transport_event_tx: mpsc::Sender, - connection_counter: Arc, - auth_policy: Arc, -} - -async fn health_check_handler() -> StatusCode { - StatusCode::OK -} - -async fn reject_requests_with_origin_header( - request: Request, - next: Next, -) -> Result { - if request.headers().contains_key(ORIGIN) { - warn!( - method = %request.method(), - uri = %request.uri(), - "rejecting websocket listener request with Origin header" - ); - Err(StatusCode::FORBIDDEN) - } else { - Ok(next.run(request).await) - } -} - -async fn websocket_upgrade_handler( - websocket: WebSocketUpgrade, - ConnectInfo(peer_addr): ConnectInfo, - State(state): State, - headers: HeaderMap, -) -> impl IntoResponse { - if let Err(err) = authorize_upgrade(&headers, state.auth_policy.as_ref()) { - warn!( - %peer_addr, - message = err.message(), - "rejecting websocket client during upgrade" - ); - return (err.status_code(), err.message()).into_response(); - } - let connection_id = ConnectionId(state.connection_counter.fetch_add(1, Ordering::Relaxed)); - info!(%peer_addr, "websocket client connected"); - websocket - .on_upgrade(move |stream| async move { - run_websocket_connection(connection_id, stream, state.transport_event_tx).await; - }) - .into_response() -} +pub(crate) use remote_control::RemoteControlHandle; +pub(crate) use remote_control::start_remote_control; +pub(crate) use stdio::start_stdio_connection; +pub(crate) use websocket::start_websocket_acceptor; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AppServerTransport { Stdio, WebSocket { bind_address: SocketAddr }, + Off, } #[derive(Debug, Clone, Eq, PartialEq)] @@ -161,7 +56,7 @@ impl std::fmt::Display for AppServerTransportParseError { match self { AppServerTransportParseError::UnsupportedListenUrl(listen_url) => write!( f, - "unsupported --listen URL `{listen_url}`; expected `stdio://` or `ws://IP:PORT`" + "unsupported --listen URL `{listen_url}`; expected `stdio://`, `ws://IP:PORT`, or `off`" ), AppServerTransportParseError::InvalidWebSocketListenUrl(listen_url) => write!( f, @@ -181,6 +76,10 @@ impl AppServerTransport { return Ok(Self::Stdio); } + if listen_url == "off" { + return Ok(Self::Off); + } + if let Some(socket_addr) = listen_url.strip_prefix("ws://") { let bind_address = socket_addr.parse::().map_err(|_| { AppServerTransportParseError::InvalidWebSocketListenUrl(listen_url.to_string()) @@ -276,260 +175,10 @@ impl OutboundConnectionState { } } -pub(crate) async fn start_stdio_connection( - transport_event_tx: mpsc::Sender, - stdio_handles: &mut Vec>, -) -> IoResult<()> { - let connection_id = ConnectionId(0); - let (writer_tx, mut writer_rx) = mpsc::channel::(CHANNEL_CAPACITY); - let writer_tx_for_reader = writer_tx.clone(); - transport_event_tx - .send(TransportEvent::ConnectionOpened { - connection_id, - writer: writer_tx, - disconnect_sender: None, - }) - .await - .map_err(|_| std::io::Error::new(ErrorKind::BrokenPipe, "processor unavailable"))?; +static CONNECTION_ID_COUNTER: AtomicU64 = AtomicU64::new(0); - let transport_event_tx_for_reader = transport_event_tx.clone(); - stdio_handles.push(tokio::spawn(async move { - let stdin = io::stdin(); - let reader = BufReader::new(stdin); - let mut lines = reader.lines(); - - loop { - match lines.next_line().await { - Ok(Some(line)) => { - if !forward_incoming_message( - &transport_event_tx_for_reader, - &writer_tx_for_reader, - connection_id, - &line, - ) - .await - { - break; - } - } - Ok(None) => break, - Err(err) => { - error!("Failed reading stdin: {err}"); - break; - } - } - } - - let _ = transport_event_tx_for_reader - .send(TransportEvent::ConnectionClosed { connection_id }) - .await; - debug!("stdin reader finished (EOF)"); - })); - - stdio_handles.push(tokio::spawn(async move { - let mut stdout = io::stdout(); - while let Some(queued_message) = writer_rx.recv().await { - let Some(mut json) = serialize_outgoing_message(queued_message.message) else { - continue; - }; - json.push('\n'); - if let Err(err) = stdout.write_all(json.as_bytes()).await { - error!("Failed to write to stdout: {err}"); - break; - } - if let Some(write_complete_tx) = queued_message.write_complete_tx { - let _ = write_complete_tx.send(()); - } - } - info!("stdout writer exited (channel closed)"); - })); - - Ok(()) -} - -pub(crate) async fn start_websocket_acceptor( - bind_address: SocketAddr, - transport_event_tx: mpsc::Sender, - shutdown_token: CancellationToken, - auth_policy: WebsocketAuthPolicy, -) -> IoResult> { - if should_warn_about_unauthenticated_non_loopback_listener(bind_address, &auth_policy) { - warn!( - %bind_address, - "starting non-loopback websocket listener without auth; websocket auth is opt-in for now and will become the default in a future release" - ); - } - let listener = TcpListener::bind(bind_address).await?; - let local_addr = listener.local_addr()?; - print_websocket_startup_banner(local_addr); - info!("app-server websocket listening on ws://{local_addr}"); - - let router = Router::new() - .route("/readyz", get(health_check_handler)) - .route("/healthz", get(health_check_handler)) - .fallback(any(websocket_upgrade_handler)) - .layer(middleware::from_fn(reject_requests_with_origin_header)) - .with_state(WebSocketListenerState { - transport_event_tx, - connection_counter: Arc::new(AtomicU64::new(1)), - auth_policy: Arc::new(auth_policy), - }); - let server = axum::serve( - listener, - router.into_make_service_with_connect_info::(), - ) - .with_graceful_shutdown(async move { - shutdown_token.cancelled().await; - }); - Ok(tokio::spawn(async move { - if let Err(err) = server.await { - error!("websocket acceptor failed: {err}"); - } - info!("websocket acceptor shutting down"); - })) -} - -async fn run_websocket_connection( - connection_id: ConnectionId, - websocket_stream: WebSocket, - transport_event_tx: mpsc::Sender, -) { - let (writer_tx, writer_rx) = mpsc::channel::(CHANNEL_CAPACITY); - let writer_tx_for_reader = writer_tx.clone(); - let disconnect_token = CancellationToken::new(); - if transport_event_tx - .send(TransportEvent::ConnectionOpened { - connection_id, - writer: writer_tx, - disconnect_sender: Some(disconnect_token.clone()), - }) - .await - .is_err() - { - return; - } - - let (websocket_writer, websocket_reader) = websocket_stream.split(); - let (writer_control_tx, writer_control_rx) = - mpsc::channel::(CHANNEL_CAPACITY); - let mut outbound_task = tokio::spawn(run_websocket_outbound_loop( - websocket_writer, - writer_rx, - writer_control_rx, - disconnect_token.clone(), - )); - let mut inbound_task = tokio::spawn(run_websocket_inbound_loop( - websocket_reader, - transport_event_tx.clone(), - writer_tx_for_reader, - writer_control_tx, - connection_id, - disconnect_token.clone(), - )); - - tokio::select! { - _ = &mut outbound_task => { - disconnect_token.cancel(); - inbound_task.abort(); - } - _ = &mut inbound_task => { - disconnect_token.cancel(); - outbound_task.abort(); - } - } - - let _ = transport_event_tx - .send(TransportEvent::ConnectionClosed { connection_id }) - .await; -} - -async fn run_websocket_outbound_loop( - mut websocket_writer: futures::stream::SplitSink, - mut writer_rx: mpsc::Receiver, - mut writer_control_rx: mpsc::Receiver, - disconnect_token: CancellationToken, -) { - loop { - tokio::select! { - _ = disconnect_token.cancelled() => { - break; - } - message = writer_control_rx.recv() => { - let Some(message) = message else { - break; - }; - if websocket_writer.send(message).await.is_err() { - break; - } - } - queued_message = writer_rx.recv() => { - let Some(queued_message) = queued_message else { - break; - }; - let Some(json) = serialize_outgoing_message(queued_message.message) else { - continue; - }; - if websocket_writer.send(WebSocketMessage::Text(json.into())).await.is_err() { - break; - } - if let Some(write_complete_tx) = queued_message.write_complete_tx { - let _ = write_complete_tx.send(()); - } - } - } - } -} - -async fn run_websocket_inbound_loop( - mut websocket_reader: futures::stream::SplitStream, - transport_event_tx: mpsc::Sender, - writer_tx_for_reader: mpsc::Sender, - writer_control_tx: mpsc::Sender, - connection_id: ConnectionId, - disconnect_token: CancellationToken, -) { - loop { - tokio::select! { - _ = disconnect_token.cancelled() => { - break; - } - incoming_message = websocket_reader.next() => { - match incoming_message { - Some(Ok(WebSocketMessage::Text(text))) => { - if !forward_incoming_message( - &transport_event_tx, - &writer_tx_for_reader, - connection_id, - text.as_ref(), - ) - .await - { - break; - } - } - Some(Ok(WebSocketMessage::Ping(payload))) => { - match writer_control_tx.try_send(WebSocketMessage::Pong(payload)) { - Ok(()) => {} - Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => break, - Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { - warn!("websocket control queue full while replying to ping; closing connection"); - break; - } - } - } - Some(Ok(WebSocketMessage::Pong(_))) => {} - Some(Ok(WebSocketMessage::Close(_))) | None => break, - Some(Ok(WebSocketMessage::Binary(_))) => { - warn!("dropping unsupported binary websocket message"); - } - Some(Err(err)) => { - warn!("websocket receive error: {err}"); - break; - } - } - } - } - } +fn next_connection_id() -> ConnectionId { + ConnectionId(CONNECTION_ID_COUNTER.fetch_add(1, Ordering::Relaxed)) } async fn forward_incoming_message( @@ -744,9 +393,11 @@ pub(crate) async fn route_outgoing_envelope( #[cfg(test)] mod tests { use super::*; - use crate::error_code::OVERLOADED_ERROR_CODE; - use codex_app_server_protocol::CommandExecutionRequestApprovalSkillMetadata; use codex_app_server_protocol::ConfigWarningNotification; + use codex_app_server_protocol::JSONRPCNotification; + use codex_app_server_protocol::JSONRPCRequest; + use codex_app_server_protocol::JSONRPCResponse; + use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -760,41 +411,10 @@ mod tests { } #[test] - fn app_server_transport_parses_stdio_listen_url() { - let transport = AppServerTransport::from_listen_url(AppServerTransport::DEFAULT_LISTEN_URL) - .expect("stdio listen URL should parse"); - assert_eq!(transport, AppServerTransport::Stdio); - } - - #[test] - fn app_server_transport_parses_websocket_listen_url() { - let transport = AppServerTransport::from_listen_url("ws://127.0.0.1:1234") - .expect("websocket listen URL should parse"); + fn listen_off_parses_as_off_transport() { assert_eq!( - transport, - AppServerTransport::WebSocket { - bind_address: "127.0.0.1:1234".parse().expect("valid socket address"), - } - ); - } - - #[test] - fn app_server_transport_rejects_invalid_websocket_listen_url() { - let err = AppServerTransport::from_listen_url("ws://localhost:1234") - .expect_err("hostname bind address should be rejected"); - assert_eq!( - err.to_string(), - "invalid websocket --listen URL `ws://localhost:1234`; expected `ws://IP:PORT`" - ); - } - - #[test] - fn app_server_transport_rejects_unsupported_listen_url() { - let err = AppServerTransport::from_listen_url("http://127.0.0.1:1234") - .expect_err("unsupported scheme should fail"); - assert_eq!( - err.to_string(), - "unsupported --listen URL `http://127.0.0.1:1234`; expected `stdio://` or `ws://IP:PORT`" + AppServerTransport::from_listen_url("off"), + Ok(AppServerTransport::Off) ); } @@ -804,11 +424,10 @@ mod tests { let (transport_event_tx, mut transport_event_rx) = mpsc::channel(1); let (writer_tx, mut writer_rx) = mpsc::channel(1); - let first_message = - JSONRPCMessage::Notification(codex_app_server_protocol::JSONRPCNotification { - method: "initialized".to_string(), - params: None, - }); + let first_message = JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); transport_event_tx .send(TransportEvent::IncomingMessage { connection_id, @@ -817,8 +436,8 @@ mod tests { .await .expect("queue should accept first message"); - let request = JSONRPCMessage::Request(codex_app_server_protocol::JSONRPCRequest { - id: codex_app_server_protocol::RequestId::Integer(7), + let request = JSONRPCMessage::Request(JSONRPCRequest { + id: RequestId::Integer(7), method: "config/read".to_string(), params: Some(json!({ "includeLayers": false })), trace: None, @@ -866,11 +485,10 @@ mod tests { let (transport_event_tx, mut transport_event_rx) = mpsc::channel(1); let (writer_tx, _writer_rx) = mpsc::channel(1); - let first_message = - JSONRPCMessage::Notification(codex_app_server_protocol::JSONRPCNotification { - method: "initialized".to_string(), - params: None, - }); + let first_message = JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); transport_event_tx .send(TransportEvent::IncomingMessage { connection_id, @@ -879,8 +497,8 @@ mod tests { .await .expect("queue should accept first message"); - let response = JSONRPCMessage::Response(codex_app_server_protocol::JSONRPCResponse { - id: codex_app_server_protocol::RequestId::Integer(7), + let response = JSONRPCMessage::Response(JSONRPCResponse { + id: RequestId::Integer(7), result: json!({"ok": true}), }); let transport_event_tx_for_enqueue = transport_event_tx.clone(); @@ -920,11 +538,10 @@ mod tests { match forwarded_event { TransportEvent::IncomingMessage { connection_id: queued_connection_id, - message: - JSONRPCMessage::Response(codex_app_server_protocol::JSONRPCResponse { id, result }), + message: JSONRPCMessage::Response(JSONRPCResponse { id, result }), } => { assert_eq!(queued_connection_id, connection_id); - assert_eq!(id, codex_app_server_protocol::RequestId::Integer(7)); + assert_eq!(id, RequestId::Integer(7)); assert_eq!(result, json!({"ok": true})); } _ => panic!("expected forwarded response message"), @@ -940,12 +557,10 @@ mod tests { transport_event_tx .send(TransportEvent::IncomingMessage { connection_id, - message: JSONRPCMessage::Notification( - codex_app_server_protocol::JSONRPCNotification { - method: "initialized".to_string(), - params: None, - }, - ), + message: JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }), }) .await .expect("transport queue should accept first message"); @@ -964,15 +579,15 @@ mod tests { .await .expect("writer queue should accept first message"); - let request = JSONRPCMessage::Request(codex_app_server_protocol::JSONRPCRequest { - id: codex_app_server_protocol::RequestId::Integer(7), + let request = JSONRPCMessage::Request(JSONRPCRequest { + id: RequestId::Integer(7), method: "config/read".to_string(), params: Some(json!({ "includeLayers": false })), trace: None, }); - let enqueue_result = tokio::time::timeout( - std::time::Duration::from_millis(100), + let enqueue_result = timeout( + Duration::from_millis(100), enqueue_incoming_message(&transport_event_tx, &writer_tx, connection_id, request), ) .await @@ -1013,7 +628,7 @@ mod tests { initialized, Arc::new(AtomicBool::new(true)), opted_out_notification_methods, - None, + /*disconnect_sender*/ None, ), ); @@ -1053,7 +668,7 @@ mod tests { Arc::new(AtomicBool::new(true)), Arc::new(AtomicBool::new(true)), Arc::new(RwLock::new(HashSet::from(["configWarning".to_string()]))), - None, + /*disconnect_sender*/ None, ), ); @@ -1093,7 +708,7 @@ mod tests { Arc::new(AtomicBool::new(true)), Arc::new(AtomicBool::new(true)), Arc::new(RwLock::new(HashSet::new())), - None, + /*disconnect_sender*/ None, ), ); @@ -1127,7 +742,7 @@ mod tests { } #[tokio::test] - async fn command_execution_request_approval_strips_experimental_fields_without_capability() { + async fn command_execution_request_approval_strips_additional_permissions_without_capability() { let connection_id = ConnectionId(8); let (writer_tx, mut writer_rx) = mpsc::channel(1); @@ -1139,7 +754,7 @@ mod tests { Arc::new(AtomicBool::new(true)), Arc::new(AtomicBool::new(false)), Arc::new(RwLock::new(HashSet::new())), - None, + /*disconnect_sender*/ None, ), ); @@ -1148,7 +763,7 @@ mod tests { OutgoingEnvelope::ToConnection { connection_id, message: OutgoingMessage::Request(ServerRequest::CommandExecutionRequestApproval { - request_id: codex_app_server_protocol::RequestId::Integer(1), + request_id: RequestId::Integer(1), params: codex_app_server_protocol::CommandExecutionRequestApprovalParams { thread_id: "thr_123".to_string(), turn_id: "turn_123".to_string(), @@ -1168,12 +783,8 @@ mod tests { write: None, }, ), - macos: None, }, ), - skill_metadata: Some(CommandExecutionRequestApprovalSkillMetadata { - path_to_skills_md: PathBuf::from("/tmp/SKILLS.md"), - }), proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, available_decisions: None, @@ -1190,11 +801,10 @@ mod tests { .expect("request should be delivered to the connection"); let json = serde_json::to_value(message.message).expect("request should serialize"); assert_eq!(json["params"].get("additionalPermissions"), None); - assert_eq!(json["params"].get("skillMetadata"), None); } #[tokio::test] - async fn command_execution_request_approval_keeps_experimental_fields_with_capability() { + async fn command_execution_request_approval_keeps_additional_permissions_with_capability() { let connection_id = ConnectionId(9); let (writer_tx, mut writer_rx) = mpsc::channel(1); @@ -1206,7 +816,7 @@ mod tests { Arc::new(AtomicBool::new(true)), Arc::new(AtomicBool::new(true)), Arc::new(RwLock::new(HashSet::new())), - None, + /*disconnect_sender*/ None, ), ); @@ -1215,7 +825,7 @@ mod tests { OutgoingEnvelope::ToConnection { connection_id, message: OutgoingMessage::Request(ServerRequest::CommandExecutionRequestApproval { - request_id: codex_app_server_protocol::RequestId::Integer(1), + request_id: RequestId::Integer(1), params: codex_app_server_protocol::CommandExecutionRequestApprovalParams { thread_id: "thr_123".to_string(), turn_id: "turn_123".to_string(), @@ -1235,12 +845,8 @@ mod tests { write: None, }, ), - macos: None, }, ), - skill_metadata: Some(CommandExecutionRequestApprovalSkillMetadata { - path_to_skills_md: PathBuf::from("/tmp/SKILLS.md"), - }), proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, available_decisions: None, @@ -1263,15 +869,8 @@ mod tests { "network": null, "fileSystem": { "read": [allowed_path], - "write": null, + "write": null, }, - "macos": null, - }) - ); - assert_eq!( - json["params"]["skillMetadata"], - json!({ - "pathToSkillsMd": "/tmp/SKILLS.md", }) ); } @@ -1389,7 +988,7 @@ mod tests { Arc::new(AtomicBool::new(true)), Arc::new(AtomicBool::new(true)), Arc::new(RwLock::new(HashSet::new())), - None, + /*disconnect_sender*/ None, ), ); diff --git a/codex-rs/app-server/src/transport/remote_control/client_tracker.rs b/codex-rs/app-server/src/transport/remote_control/client_tracker.rs new file mode 100644 index 0000000000..fa9a208ade --- /dev/null +++ b/codex-rs/app-server/src/transport/remote_control/client_tracker.rs @@ -0,0 +1,568 @@ +use super::CHANNEL_CAPACITY; +use super::TransportEvent; +use super::next_connection_id; +use super::protocol::ClientEnvelope; +pub use super::protocol::ClientEvent; +pub use super::protocol::ClientId; +use super::protocol::PongStatus; +use super::protocol::ServerEvent; +use super::protocol::StreamId; +use crate::outgoing_message::ConnectionId; +use crate::outgoing_message::QueuedOutgoingMessage; +use crate::transport::remote_control::QueuedServerEnvelope; +use codex_app_server_protocol::JSONRPCMessage; +use std::collections::HashMap; +use tokio::sync::mpsc; +use tokio::sync::watch; +use tokio::task::JoinSet; +use tokio::time::Duration; +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; + +const REMOTE_CONTROL_CLIENT_IDLE_TIMEOUT: Duration = Duration::from_secs(10 * 60); +pub(crate) const REMOTE_CONTROL_IDLE_SWEEP_INTERVAL: Duration = Duration::from_secs(30); + +#[derive(Debug)] +pub(crate) struct Stopped; + +struct ClientState { + connection_id: ConnectionId, + disconnect_token: CancellationToken, + last_activity_at: Instant, + last_inbound_seq_id: Option, + status_tx: watch::Sender, +} + +pub(crate) struct ClientTracker { + clients: HashMap<(ClientId, StreamId), ClientState>, + legacy_stream_ids: HashMap, + join_set: JoinSet<(ClientId, StreamId)>, + server_event_tx: mpsc::Sender, + transport_event_tx: mpsc::Sender, + shutdown_token: CancellationToken, +} + +impl ClientTracker { + pub(crate) fn new( + server_event_tx: mpsc::Sender, + transport_event_tx: mpsc::Sender, + shutdown_token: &CancellationToken, + ) -> Self { + Self { + clients: HashMap::new(), + legacy_stream_ids: HashMap::new(), + join_set: JoinSet::new(), + server_event_tx, + transport_event_tx, + shutdown_token: shutdown_token.child_token(), + } + } + + pub(crate) async fn bookkeep_join_set(&mut self) -> Option<(ClientId, StreamId)> { + while let Some(join_result) = self.join_set.join_next().await { + let Ok(client_key) = join_result else { + continue; + }; + return Some(client_key); + } + futures::future::pending().await + } + + pub(crate) async fn shutdown(&mut self) { + self.shutdown_token.cancel(); + + while let Some(client_key) = self.clients.keys().next().cloned() { + let _ = self.close_client(&client_key).await; + } + + self.drain_join_set().await; + } + + async fn drain_join_set(&mut self) { + while self.join_set.join_next().await.is_some() {} + } + + pub(crate) async fn handle_message( + &mut self, + client_envelope: ClientEnvelope, + ) -> Result<(), Stopped> { + let ClientEnvelope { + client_id, + event, + stream_id, + seq_id, + cursor: _, + } = client_envelope; + let is_legacy_stream_id = stream_id.is_none(); + let is_initialize = matches!(&event, ClientEvent::ClientMessage { message } if remote_control_message_starts_connection(message)); + let stream_id = match stream_id { + Some(stream_id) => stream_id, + None if is_initialize => { + // TODO(ruslan): delete this fallback once all clients are updated to send stream_id. + self.legacy_stream_ids + .remove(&client_id) + .unwrap_or_else(StreamId::new_random) + } + None => self + .legacy_stream_ids + .get(&client_id) + .cloned() + .unwrap_or_else(|| { + if matches!(&event, ClientEvent::Ping) { + StreamId::new_random() + } else { + StreamId(String::new()) + } + }), + }; + if stream_id.0.is_empty() { + return Ok(()); + } + let client_key = (client_id.clone(), stream_id.clone()); + match event { + ClientEvent::ClientMessage { message } => { + if let Some(seq_id) = seq_id + && let Some(client) = self.clients.get(&client_key) + && client + .last_inbound_seq_id + .is_some_and(|last_seq_id| last_seq_id >= seq_id) + && !is_initialize + { + return Ok(()); + } + + if is_initialize && self.clients.contains_key(&client_key) { + self.close_client(&client_key).await?; + } + + if let Some(connection_id) = self.clients.get_mut(&client_key).map(|client| { + client.last_activity_at = Instant::now(); + if let Some(seq_id) = seq_id { + client.last_inbound_seq_id = Some(seq_id); + } + client.connection_id + }) { + self.send_transport_event(TransportEvent::IncomingMessage { + connection_id, + message, + }) + .await?; + return Ok(()); + } + + if !is_initialize { + return Ok(()); + } + + let connection_id = next_connection_id(); + let (writer_tx, writer_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let disconnect_token = self.shutdown_token.child_token(); + self.send_transport_event(TransportEvent::ConnectionOpened { + connection_id, + writer: writer_tx, + disconnect_sender: Some(disconnect_token.clone()), + }) + .await?; + + let (status_tx, status_rx) = watch::channel(PongStatus::Active); + self.join_set.spawn(Self::run_client_outbound( + client_id.clone(), + stream_id.clone(), + self.server_event_tx.clone(), + writer_rx, + status_rx, + disconnect_token.clone(), + )); + self.clients.insert( + client_key, + ClientState { + connection_id, + disconnect_token, + last_activity_at: Instant::now(), + last_inbound_seq_id: if is_legacy_stream_id { None } else { seq_id }, + status_tx, + }, + ); + if is_legacy_stream_id { + self.legacy_stream_ids.insert(client_id.clone(), stream_id); + } + self.send_transport_event(TransportEvent::IncomingMessage { + connection_id, + message, + }) + .await + } + ClientEvent::Ack => Ok(()), + ClientEvent::Ping => { + if let Some(client) = self.clients.get_mut(&client_key) { + client.last_activity_at = Instant::now(); + let _ = client.status_tx.send(PongStatus::Active); + return Ok(()); + } + + let server_event_tx = self.server_event_tx.clone(); + tokio::spawn(async move { + let server_envelope = QueuedServerEnvelope { + event: ServerEvent::Pong { + status: PongStatus::Unknown, + }, + client_id, + stream_id, + write_complete_tx: None, + }; + let _ = server_event_tx.send(server_envelope).await; + }); + Ok(()) + } + ClientEvent::ClientClosed => self.close_client(&client_key).await, + } + } + + async fn run_client_outbound( + client_id: ClientId, + stream_id: StreamId, + server_event_tx: mpsc::Sender, + mut writer_rx: mpsc::Receiver, + mut status_rx: watch::Receiver, + disconnect_token: CancellationToken, + ) -> (ClientId, StreamId) { + loop { + let (event, write_complete_tx) = tokio::select! { + _ = disconnect_token.cancelled() => { + break; + } + queued_message = writer_rx.recv() => { + let Some(queued_message) = queued_message else { + break; + }; + let event = ServerEvent::ServerMessage { + message: Box::new(queued_message.message), + }; + (event, queued_message.write_complete_tx) + } + changed = status_rx.changed() => { + if changed.is_err() { + break; + } + let event = ServerEvent::Pong { status: status_rx.borrow().clone() }; + (event, None) + } + }; + let send_result = tokio::select! { + _ = disconnect_token.cancelled() => { + break; + } + send_result = server_event_tx.send(QueuedServerEnvelope { + event, + client_id: client_id.clone(), + stream_id: stream_id.clone(), + write_complete_tx, + }) => send_result, + }; + if send_result.is_err() { + break; + } + } + (client_id, stream_id) + } + + pub(crate) async fn close_expired_clients( + &mut self, + ) -> Result, Stopped> { + let now = Instant::now(); + let expired_client_ids: Vec<(ClientId, StreamId)> = self + .clients + .iter() + .filter_map(|(client_key, client)| { + (!remote_control_client_is_alive(client, now)).then_some(client_key.clone()) + }) + .collect(); + for client_key in &expired_client_ids { + self.close_client(client_key).await?; + } + Ok(expired_client_ids) + } + + pub(super) async fn close_client( + &mut self, + client_key: &(ClientId, StreamId), + ) -> Result<(), Stopped> { + let Some(client) = self.clients.remove(client_key) else { + return Ok(()); + }; + if self + .legacy_stream_ids + .get(&client_key.0) + .is_some_and(|stream_id| stream_id == &client_key.1) + { + self.legacy_stream_ids.remove(&client_key.0); + } + client.disconnect_token.cancel(); + self.send_transport_event(TransportEvent::ConnectionClosed { + connection_id: client.connection_id, + }) + .await + } + + async fn send_transport_event(&self, event: TransportEvent) -> Result<(), Stopped> { + self.transport_event_tx + .send(event) + .await + .map_err(|_| Stopped) + } +} + +fn remote_control_message_starts_connection(message: &JSONRPCMessage) -> bool { + matches!( + message, + JSONRPCMessage::Request(codex_app_server_protocol::JSONRPCRequest { method, .. }) + if method == "initialize" + ) +} + +fn remote_control_client_is_alive(client: &ClientState, now: Instant) -> bool { + now.duration_since(client.last_activity_at) < REMOTE_CONTROL_CLIENT_IDLE_TIMEOUT +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::outgoing_message::OutgoingMessage; + use crate::transport::remote_control::protocol::ClientEnvelope; + use crate::transport::remote_control::protocol::ClientEvent; + use codex_app_server_protocol::ConfigWarningNotification; + use codex_app_server_protocol::JSONRPCRequest; + use codex_app_server_protocol::RequestId; + use codex_app_server_protocol::ServerNotification; + use pretty_assertions::assert_eq; + use serde_json::json; + use tokio::time::timeout; + + fn initialize_envelope(client_id: &str) -> ClientEnvelope { + initialize_envelope_with_stream_id(client_id, /*stream_id*/ None) + } + + fn initialize_envelope_with_stream_id( + client_id: &str, + stream_id: Option<&str>, + ) -> ClientEnvelope { + ClientEnvelope { + event: ClientEvent::ClientMessage { + message: JSONRPCMessage::Request(JSONRPCRequest { + id: RequestId::Integer(1), + method: "initialize".to_string(), + params: Some(json!({ + "clientInfo": { + "name": "remote-test-client", + "version": "0.1.0" + } + })), + trace: None, + }), + }, + client_id: ClientId(client_id.to_string()), + stream_id: stream_id.map(|stream_id| StreamId(stream_id.to_string())), + seq_id: Some(0), + cursor: None, + } + } + + #[tokio::test] + async fn cancelled_outbound_task_emits_connection_closed() { + let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (transport_event_tx, mut transport_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let mut client_tracker = + ClientTracker::new(server_event_tx, transport_event_tx, &shutdown_token); + + client_tracker + .handle_message(initialize_envelope("client-1")) + .await + .expect("initialize should open client"); + + let (connection_id, disconnect_sender) = match transport_event_rx + .recv() + .await + .expect("connection opened should be sent") + { + TransportEvent::ConnectionOpened { + connection_id, + disconnect_sender: Some(disconnect_sender), + .. + } => (connection_id, disconnect_sender), + other => panic!("expected connection opened, got {other:?}"), + }; + match transport_event_rx + .recv() + .await + .expect("initialize should be forwarded") + { + TransportEvent::IncomingMessage { + connection_id: incoming_connection_id, + .. + } => assert_eq!(incoming_connection_id, connection_id), + other => panic!("expected incoming initialize, got {other:?}"), + } + + disconnect_sender.cancel(); + let closed_client_id = timeout(Duration::from_secs(1), client_tracker.bookkeep_join_set()) + .await + .expect("bookkeeping should process the closed task") + .expect("closed task should return client id"); + assert_eq!(closed_client_id.0, ClientId("client-1".to_string())); + client_tracker + .close_client(&closed_client_id) + .await + .expect("closed client should emit connection closed"); + + match transport_event_rx + .recv() + .await + .expect("connection closed should be sent") + { + TransportEvent::ConnectionClosed { + connection_id: closed_connection_id, + } => assert_eq!(closed_connection_id, connection_id), + other => panic!("expected connection closed, got {other:?}"), + } + } + + #[tokio::test] + async fn shutdown_cancels_blocked_outbound_forwarding() { + let (server_event_tx, _server_event_rx) = mpsc::channel(1); + let (transport_event_tx, mut transport_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let mut client_tracker = + ClientTracker::new(server_event_tx.clone(), transport_event_tx, &shutdown_token); + + server_event_tx + .send(QueuedServerEnvelope { + event: ServerEvent::Pong { + status: PongStatus::Unknown, + }, + client_id: ClientId("queued-client".to_string()), + stream_id: StreamId("queued-stream".to_string()), + write_complete_tx: None, + }) + .await + .expect("server event queue should accept prefill"); + + client_tracker + .handle_message(initialize_envelope("client-1")) + .await + .expect("initialize should open client"); + + let writer = match transport_event_rx + .recv() + .await + .expect("connection opened should be sent") + { + TransportEvent::ConnectionOpened { writer, .. } => writer, + other => panic!("expected connection opened, got {other:?}"), + }; + let _ = transport_event_rx + .recv() + .await + .expect("initialize should be forwarded"); + + writer + .send(QueuedOutgoingMessage::new( + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "test".to_string(), + details: None, + path: None, + range: None, + }, + )), + )) + .await + .expect("writer should accept queued message"); + + timeout(Duration::from_secs(1), client_tracker.shutdown()) + .await + .expect("shutdown should not hang on blocked server forwarding"); + } + + #[tokio::test] + async fn initialize_with_new_stream_id_opens_new_connection_for_same_client() { + let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (transport_event_tx, mut transport_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let mut client_tracker = + ClientTracker::new(server_event_tx, transport_event_tx, &shutdown_token); + + client_tracker + .handle_message(initialize_envelope_with_stream_id( + "client-1", + Some("stream-1"), + )) + .await + .expect("first initialize should open client"); + let first_connection_id = match transport_event_rx.recv().await.expect("open event") { + TransportEvent::ConnectionOpened { connection_id, .. } => connection_id, + other => panic!("expected connection opened, got {other:?}"), + }; + let _ = transport_event_rx.recv().await.expect("initialize event"); + + client_tracker + .handle_message(initialize_envelope_with_stream_id( + "client-1", + Some("stream-2"), + )) + .await + .expect("second initialize should open client"); + let second_connection_id = match transport_event_rx.recv().await.expect("open event") { + TransportEvent::ConnectionOpened { connection_id, .. } => connection_id, + other => panic!("expected connection opened, got {other:?}"), + }; + + assert_ne!(first_connection_id, second_connection_id); + } + + #[tokio::test] + async fn legacy_initialize_without_stream_id_resets_inbound_seq_id() { + let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (transport_event_tx, mut transport_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let mut client_tracker = + ClientTracker::new(server_event_tx, transport_event_tx, &shutdown_token); + + client_tracker + .handle_message(initialize_envelope("client-1")) + .await + .expect("initialize should open client"); + let connection_id = match transport_event_rx.recv().await.expect("open event") { + TransportEvent::ConnectionOpened { connection_id, .. } => connection_id, + other => panic!("expected connection opened, got {other:?}"), + }; + let _ = transport_event_rx.recv().await.expect("initialize event"); + + client_tracker + .handle_message(ClientEnvelope { + event: ClientEvent::ClientMessage { + message: JSONRPCMessage::Notification( + codex_app_server_protocol::JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }, + ), + }, + client_id: ClientId("client-1".to_string()), + stream_id: None, + seq_id: Some(0), + cursor: None, + }) + .await + .expect("legacy followup should be forwarded"); + + match transport_event_rx.recv().await.expect("followup event") { + TransportEvent::IncomingMessage { + connection_id: incoming_connection_id, + .. + } => assert_eq!(incoming_connection_id, connection_id), + other => panic!("expected incoming message, got {other:?}"), + } + } +} diff --git a/codex-rs/app-server/src/transport/remote_control/enroll.rs b/codex-rs/app-server/src/transport/remote_control/enroll.rs new file mode 100644 index 0000000000..dbe18c8355 --- /dev/null +++ b/codex-rs/app-server/src/transport/remote_control/enroll.rs @@ -0,0 +1,503 @@ +use super::protocol::EnrollRemoteServerRequest; +use super::protocol::EnrollRemoteServerResponse; +use super::protocol::RemoteControlTarget; +use axum::http::HeaderMap; +use codex_login::default_client::build_reqwest_client; +use codex_state::RemoteControlEnrollmentRecord; +use codex_state::StateRuntime; +use gethostname::gethostname; +use std::io; +use std::io::ErrorKind; +use tracing::info; +use tracing::warn; + +const REMOTE_CONTROL_ENROLL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +const REMOTE_CONTROL_RESPONSE_BODY_MAX_BYTES: usize = 4096; + +const REQUEST_ID_HEADER: &str = "x-request-id"; +const OAI_REQUEST_ID_HEADER: &str = "x-oai-request-id"; +const CF_RAY_HEADER: &str = "cf-ray"; +pub(super) const REMOTE_CONTROL_ACCOUNT_ID_HEADER: &str = "chatgpt-account-id"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct RemoteControlEnrollment { + pub(super) account_id: String, + pub(super) environment_id: String, + pub(super) server_id: String, + pub(super) server_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct RemoteControlConnectionAuth { + pub(super) bearer_token: String, + pub(super) account_id: String, +} + +pub(super) async fn load_persisted_remote_control_enrollment( + state_db: Option<&StateRuntime>, + remote_control_target: &RemoteControlTarget, + account_id: &str, + app_server_client_name: Option<&str>, +) -> Option { + let Some(state_db) = state_db else { + info!( + "remote control enrollment cache unavailable because sqlite state db is disabled: websocket_url={}, account_id={}, app_server_client_name={:?}", + remote_control_target.websocket_url, account_id, app_server_client_name + ); + return None; + }; + let enrollment = match state_db + .get_remote_control_enrollment( + &remote_control_target.websocket_url, + account_id, + app_server_client_name, + ) + .await + { + Ok(enrollment) => enrollment, + Err(err) => { + warn!( + "failed to load persisted remote control enrollment: websocket_url={}, account_id={}, app_server_client_name={:?}, err={err}", + remote_control_target.websocket_url, account_id, app_server_client_name + ); + return None; + } + }; + + match enrollment { + Some(enrollment) => { + info!( + "reusing persisted remote control enrollment: websocket_url={}, account_id={}, app_server_client_name={:?}, server_id={}, environment_id={}", + remote_control_target.websocket_url, + account_id, + app_server_client_name, + enrollment.server_id, + enrollment.environment_id + ); + Some(RemoteControlEnrollment { + account_id: enrollment.account_id, + environment_id: enrollment.environment_id, + server_id: enrollment.server_id, + server_name: enrollment.server_name, + }) + } + None => { + info!( + "no persisted remote control enrollment found: websocket_url={}, account_id={}, app_server_client_name={:?}", + remote_control_target.websocket_url, account_id, app_server_client_name + ); + None + } + } +} + +pub(super) async fn update_persisted_remote_control_enrollment( + state_db: Option<&StateRuntime>, + remote_control_target: &RemoteControlTarget, + account_id: &str, + app_server_client_name: Option<&str>, + enrollment: Option<&RemoteControlEnrollment>, +) -> io::Result<()> { + let Some(state_db) = state_db else { + info!( + "skipping remote control enrollment persistence because sqlite state db is disabled: websocket_url={}, account_id={}, app_server_client_name={:?}, has_enrollment={}", + remote_control_target.websocket_url, + account_id, + app_server_client_name, + enrollment.is_some() + ); + return Ok(()); + }; + if let &Some(enrollment) = &enrollment + && enrollment.account_id != account_id + { + return Err(io::Error::other(format!( + "enrollment account_id does not match expected account_id `{account_id}`" + ))); + } + + if let Some(enrollment) = enrollment { + state_db + .upsert_remote_control_enrollment(&RemoteControlEnrollmentRecord { + websocket_url: remote_control_target.websocket_url.clone(), + account_id: account_id.to_string(), + app_server_client_name: app_server_client_name.map(str::to_string), + server_id: enrollment.server_id.clone(), + environment_id: enrollment.environment_id.clone(), + server_name: enrollment.server_name.clone(), + }) + .await + .map_err(io::Error::other)?; + info!( + "persisted remote control enrollment: websocket_url={}, account_id={}, app_server_client_name={:?}, server_id={}, environment_id={}", + remote_control_target.websocket_url, + account_id, + app_server_client_name, + enrollment.server_id, + enrollment.environment_id + ); + Ok(()) + } else { + let rows_affected = state_db + .delete_remote_control_enrollment( + &remote_control_target.websocket_url, + account_id, + app_server_client_name, + ) + .await + .map_err(io::Error::other)?; + info!( + "cleared persisted remote control enrollment: websocket_url={}, account_id={}, app_server_client_name={:?}, rows_affected={rows_affected}", + remote_control_target.websocket_url, account_id, app_server_client_name + ); + Ok(()) + } +} + +pub(crate) fn preview_remote_control_response_body(body: &[u8]) -> String { + let body = String::from_utf8_lossy(body); + let trimmed = body.trim(); + if trimmed.is_empty() { + return "".to_string(); + } + if trimmed.len() <= REMOTE_CONTROL_RESPONSE_BODY_MAX_BYTES { + return trimmed.to_string(); + } + + let mut cut = REMOTE_CONTROL_RESPONSE_BODY_MAX_BYTES; + while !trimmed.is_char_boundary(cut) { + cut = cut.saturating_sub(1); + } + let mut truncated = trimmed[..cut].to_string(); + truncated.push_str("..."); + truncated +} + +pub(crate) fn format_headers(headers: &HeaderMap) -> String { + let request_id_str = headers + .get(REQUEST_ID_HEADER) + .or_else(|| headers.get(OAI_REQUEST_ID_HEADER)) + .map(|value| value.to_str().unwrap_or("").to_owned()) + .unwrap_or_else(|| "".to_owned()); + let cf_ray_str = headers + .get(CF_RAY_HEADER) + .map(|value| value.to_str().unwrap_or("").to_owned()) + .unwrap_or_else(|| "".to_owned()); + format!("request-id: {request_id_str}, cf-ray: {cf_ray_str}") +} + +pub(super) async fn enroll_remote_control_server( + remote_control_target: &RemoteControlTarget, + auth: &RemoteControlConnectionAuth, +) -> io::Result { + let enroll_url = &remote_control_target.enroll_url; + let server_name = gethostname().to_string_lossy().trim().to_string(); + let request = EnrollRemoteServerRequest { + name: server_name.clone(), + os: std::env::consts::OS, + arch: std::env::consts::ARCH, + app_server_version: env!("CARGO_PKG_VERSION"), + }; + let client = build_reqwest_client(); + let http_request = client + .post(enroll_url) + .timeout(REMOTE_CONTROL_ENROLL_TIMEOUT) + .bearer_auth(&auth.bearer_token) + .header(REMOTE_CONTROL_ACCOUNT_ID_HEADER, &auth.account_id) + .json(&request); + + let response = http_request.send().await.map_err(|err| { + io::Error::other(format!( + "failed to enroll remote control server at `{enroll_url}`: {err}" + )) + })?; + let headers = response.headers().clone(); + let status = response.status(); + let body = response.bytes().await.map_err(|err| { + io::Error::other(format!( + "failed to read remote control enrollment response from `{enroll_url}`: {err}" + )) + })?; + let body_preview = preview_remote_control_response_body(&body); + if !status.is_success() { + let headers_str = format_headers(&headers); + let error_kind = if matches!(status.as_u16(), 401 | 403) { + ErrorKind::PermissionDenied + } else { + ErrorKind::Other + }; + return Err(io::Error::new( + error_kind, + format!( + "remote control server enrollment failed at `{enroll_url}`: HTTP {status}, {headers_str}, body: {body_preview}" + ), + )); + } + + let enrollment = serde_json::from_slice::(&body).map_err(|err| { + let headers_str = format_headers(&headers); + io::Error::other(format!( + "failed to parse remote control enrollment response from `{enroll_url}`: HTTP {status}, {headers_str}, body: {body_preview}, decode error: {err}" + )) + })?; + + Ok(RemoteControlEnrollment { + account_id: auth.account_id.clone(), + environment_id: enrollment.environment_id, + server_id: enrollment.server_id, + server_name, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::transport::remote_control::protocol::normalize_remote_control_url; + use codex_state::StateRuntime; + use pretty_assertions::assert_eq; + use serde_json::json; + use std::sync::Arc; + use tempfile::TempDir; + use tokio::io::AsyncBufReadExt; + use tokio::io::AsyncWriteExt; + use tokio::io::BufReader; + use tokio::net::TcpListener; + use tokio::net::TcpStream; + use tokio::time::Duration; + use tokio::time::timeout; + + async fn remote_control_state_runtime(codex_home: &TempDir) -> Arc { + StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string()) + .await + .expect("state runtime should initialize") + } + + #[tokio::test] + async fn persisted_remote_control_enrollment_round_trips_by_target_and_account() { + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let first_target = normalize_remote_control_url("https://chatgpt.com/remote/control") + .expect("first target should parse"); + let second_target = + normalize_remote_control_url("https://api.chatgpt-staging.com/other/control") + .expect("second target should parse"); + let first_enrollment = RemoteControlEnrollment { + account_id: "account-a".to_string(), + environment_id: "env_first".to_string(), + server_id: "srv_e_first".to_string(), + server_name: "first-server".to_string(), + }; + let second_enrollment = RemoteControlEnrollment { + account_id: "account-a".to_string(), + environment_id: "env_second".to_string(), + server_id: "srv_e_second".to_string(), + server_name: "second-server".to_string(), + }; + + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &first_target, + "account-a", + Some("desktop-client"), + Some(&first_enrollment), + ) + .await + .expect("first enrollment should persist"); + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &second_target, + "account-a", + Some("desktop-client"), + Some(&second_enrollment), + ) + .await + .expect("second enrollment should persist"); + + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &first_target, + "account-a", + Some("desktop-client"), + ) + .await, + Some(first_enrollment.clone()) + ); + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &first_target, + "account-b", + Some("desktop-client"), + ) + .await, + None + ); + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &second_target, + "account-a", + Some("desktop-client"), + ) + .await, + Some(second_enrollment) + ); + } + + #[tokio::test] + async fn clearing_persisted_remote_control_enrollment_removes_only_matching_entry() { + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let first_target = normalize_remote_control_url("https://chatgpt.com/remote/control") + .expect("first target should parse"); + let second_target = + normalize_remote_control_url("https://api.chatgpt-staging.com/other/control") + .expect("second target should parse"); + let first_enrollment = RemoteControlEnrollment { + account_id: "account-a".to_string(), + environment_id: "env_first".to_string(), + server_id: "srv_e_first".to_string(), + server_name: "first-server".to_string(), + }; + let second_enrollment = RemoteControlEnrollment { + account_id: "account-a".to_string(), + environment_id: "env_second".to_string(), + server_id: "srv_e_second".to_string(), + server_name: "second-server".to_string(), + }; + + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &first_target, + "account-a", + /*app_server_client_name*/ None, + Some(&first_enrollment), + ) + .await + .expect("first enrollment should persist"); + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &second_target, + "account-a", + /*app_server_client_name*/ None, + Some(&second_enrollment), + ) + .await + .expect("second enrollment should persist"); + + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &first_target, + "account-a", + /*app_server_client_name*/ None, + /*enrollment*/ None, + ) + .await + .expect("matching enrollment should clear"); + + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &first_target, + "account-a", + /*app_server_client_name*/ None, + ) + .await, + None + ); + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &second_target, + "account-a", + /*app_server_client_name*/ None, + ) + .await, + Some(second_enrollment) + ); + } + + #[tokio::test] + async fn enroll_remote_control_server_parse_failure_includes_response_body() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = format!( + "http://127.0.0.1:{}/backend-api/", + listener + .local_addr() + .expect("listener should have a local addr") + .port() + ); + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let enroll_url = remote_control_target.enroll_url.clone(); + let response_body = json!({ + "error": "not enrolled", + }); + let expected_body = response_body.to_string(); + let server_task = tokio::spawn(async move { + let stream = accept_http_request(&listener).await; + respond_with_json(stream, response_body).await; + }); + + let err = enroll_remote_control_server( + &remote_control_target, + &RemoteControlConnectionAuth { + bearer_token: "Access Token".to_string(), + account_id: "account_id".to_string(), + }, + ) + .await + .expect_err("invalid response should fail to parse"); + + server_task.await.expect("server task should succeed"); + assert_eq!( + err.to_string(), + format!( + "failed to parse remote control enrollment response from `{enroll_url}`: HTTP 200 OK, request-id: , cf-ray: , body: {expected_body}, decode error: missing field `server_id` at line 1 column {}", + expected_body.len() + ) + ); + } + + async fn accept_http_request(listener: &TcpListener) -> TcpStream { + let (stream, _) = timeout(Duration::from_secs(5), listener.accept()) + .await + .expect("HTTP request should arrive in time") + .expect("listener accept should succeed"); + let mut reader = BufReader::new(stream); + + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .await + .expect("request line should read"); + loop { + let mut line = String::new(); + reader + .read_line(&mut line) + .await + .expect("header line should read"); + if line == "\r\n" { + break; + } + } + + reader.into_inner() + } + + async fn respond_with_json(mut stream: TcpStream, body: serde_json::Value) { + let body = body.to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .await + .expect("response should write"); + stream.flush().await.expect("response should flush"); + } +} diff --git a/codex-rs/app-server/src/transport/remote_control/mod.rs b/codex-rs/app-server/src/transport/remote_control/mod.rs new file mode 100644 index 0000000000..1ea89bb643 --- /dev/null +++ b/codex-rs/app-server/src/transport/remote_control/mod.rs @@ -0,0 +1,100 @@ +mod client_tracker; +mod enroll; +mod protocol; +mod websocket; + +use crate::transport::remote_control::websocket::RemoteControlWebsocket; +use crate::transport::remote_control::websocket::load_remote_control_auth; + +pub use self::protocol::ClientId; +use self::protocol::ServerEvent; +use self::protocol::StreamId; +use self::protocol::normalize_remote_control_url; +use super::CHANNEL_CAPACITY; +use super::TransportEvent; +use super::next_connection_id; +use codex_login::AuthManager; +use codex_state::StateRuntime; +use std::io; +use std::sync::Arc; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::sync::watch; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +pub(super) struct QueuedServerEnvelope { + pub(super) event: ServerEvent, + pub(super) client_id: ClientId, + pub(super) stream_id: StreamId, + pub(super) write_complete_tx: Option>, +} + +#[derive(Clone)] +pub(crate) struct RemoteControlHandle { + enabled_tx: Arc>, +} + +impl RemoteControlHandle { + pub(crate) fn set_enabled(&self, enabled: bool) { + self.enabled_tx.send_if_modified(|state| { + let changed = *state != enabled; + *state = enabled; + changed + }); + } +} + +pub(crate) async fn start_remote_control( + remote_control_url: String, + state_db: Option>, + auth_manager: Arc, + transport_event_tx: mpsc::Sender, + shutdown_token: CancellationToken, + app_server_client_name_rx: Option>, + initial_enabled: bool, +) -> io::Result<(JoinHandle<()>, RemoteControlHandle)> { + let remote_control_target = if initial_enabled { + Some(normalize_remote_control_url(&remote_control_url)?) + } else { + None + }; + if initial_enabled { + validate_remote_control_auth(&auth_manager).await?; + } + + let (enabled_tx, enabled_rx) = watch::channel(initial_enabled); + let join_handle = tokio::spawn(async move { + RemoteControlWebsocket::new( + remote_control_url, + remote_control_target, + state_db, + auth_manager, + transport_event_tx, + shutdown_token, + enabled_rx, + ) + .run(app_server_client_name_rx) + .await; + }); + + Ok(( + join_handle, + RemoteControlHandle { + enabled_tx: Arc::new(enabled_tx), + }, + )) +} + +pub(crate) async fn validate_remote_control_auth( + auth_manager: &Arc, +) -> io::Result<()> { + match load_remote_control_auth(auth_manager).await { + Ok(_) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::WouldBlock => Ok(()), + Err(err) => Err(err), + } +} + +#[cfg(test)] +mod tests; diff --git a/codex-rs/app-server/src/transport/remote_control/protocol.rs b/codex-rs/app-server/src/transport/remote_control/protocol.rs new file mode 100644 index 0000000000..857855f2a0 --- /dev/null +++ b/codex-rs/app-server/src/transport/remote_control/protocol.rs @@ -0,0 +1,252 @@ +use crate::outgoing_message::OutgoingMessage; +use codex_app_server_protocol::JSONRPCMessage; +use serde::Deserialize; +use serde::Serialize; +use std::io; +use std::io::ErrorKind; +use url::Host; +use url::Url; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct RemoteControlTarget { + pub(super) websocket_url: String, + pub(super) enroll_url: String, +} + +#[derive(Debug, Serialize)] +pub(super) struct EnrollRemoteServerRequest { + pub(super) name: String, + pub(super) os: &'static str, + pub(super) arch: &'static str, + pub(super) app_server_version: &'static str, +} + +#[derive(Debug, Deserialize)] +pub(super) struct EnrollRemoteServerResponse { + pub(super) server_id: String, + pub(super) environment_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ClientId(pub String); + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct StreamId(pub String); + +impl StreamId { + pub fn new_random() -> Self { + Self(uuid::Uuid::now_v7().to_string()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ClientEvent { + ClientMessage { + message: JSONRPCMessage, + }, + /// Backend-generated acknowledgement for all server envelopes addressed to + /// `client_id` whose envelope `seq_id` is less than or equal to this ack's + /// `seq_id`. This cursor is client-scoped, not stream-scoped, so receivers + /// must not use `stream_id` to partition acks. + Ack, + Ping, + ClientClosed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) struct ClientEnvelope { + #[serde(flatten)] + pub(crate) event: ClientEvent, + #[serde(rename = "client_id")] + pub(crate) client_id: ClientId, + #[serde(rename = "stream_id", skip_serializing_if = "Option::is_none")] + pub(crate) stream_id: Option, + /// For `Ack`, this is the backend-generated per-client cursor over + /// `ServerEnvelope.seq_id`. + #[serde(rename = "seq_id", skip_serializing_if = "Option::is_none")] + pub(crate) seq_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PongStatus { + Active, + Unknown, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ServerEvent { + ServerMessage { + message: Box, + }, + #[allow(dead_code)] + Ack, + Pong { + status: PongStatus, + }, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) struct ServerEnvelope { + #[serde(flatten)] + pub(crate) event: ServerEvent, + #[serde(rename = "client_id")] + pub(crate) client_id: ClientId, + #[serde(rename = "stream_id")] + pub(crate) stream_id: StreamId, + #[serde(rename = "seq_id")] + pub(crate) seq_id: u64, +} + +fn is_allowed_chatgpt_host(host: &Option>) -> bool { + let Some(Host::Domain(host)) = *host else { + return false; + }; + host == "chatgpt.com" + || host == "chatgpt-staging.com" + || host.ends_with(".chatgpt.com") + || host.ends_with(".chatgpt-staging.com") +} + +fn is_localhost(host: &Option>) -> bool { + match host { + Some(Host::Domain("localhost")) => true, + Some(Host::Ipv4(ip)) => ip.is_loopback(), + Some(Host::Ipv6(ip)) => ip.is_loopback(), + _ => false, + } +} + +pub(super) fn normalize_remote_control_url( + remote_control_url: &str, +) -> io::Result { + let map_url_parse_error = |err: url::ParseError| -> io::Error { + io::Error::new( + ErrorKind::InvalidInput, + format!("invalid remote control URL `{remote_control_url}`: {err}"), + ) + }; + let map_scheme_error = |_: ()| -> io::Error { + io::Error::new( + ErrorKind::InvalidInput, + format!( + "invalid remote control URL `{remote_control_url}`; expected HTTPS URL for chatgpt.com or chatgpt-staging.com, or HTTP/HTTPS URL for localhost" + ), + ) + }; + + let mut remote_control_url = Url::parse(remote_control_url).map_err(map_url_parse_error)?; + if !remote_control_url.path().ends_with('/') { + let normalized_path = format!("{}/", remote_control_url.path()); + remote_control_url.set_path(&normalized_path); + } + + let enroll_url = remote_control_url + .join("wham/remote/control/server/enroll") + .map_err(map_url_parse_error)?; + let mut websocket_url = remote_control_url + .join("wham/remote/control/server") + .map_err(map_url_parse_error)?; + let host = enroll_url.host(); + match enroll_url.scheme() { + "https" if is_localhost(&host) || is_allowed_chatgpt_host(&host) => { + websocket_url.set_scheme("wss").map_err(map_scheme_error)?; + } + "http" if is_localhost(&host) => { + websocket_url.set_scheme("ws").map_err(map_scheme_error)?; + } + _ => return Err(map_scheme_error(())), + } + + Ok(RemoteControlTarget { + websocket_url: websocket_url.to_string(), + enroll_url: enroll_url.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn normalize_remote_control_url_accepts_chatgpt_https_urls() { + assert_eq!( + normalize_remote_control_url("https://chatgpt.com/backend-api") + .expect("chatgpt.com URL should normalize"), + RemoteControlTarget { + websocket_url: "wss://chatgpt.com/backend-api/wham/remote/control/server" + .to_string(), + enroll_url: "https://chatgpt.com/backend-api/wham/remote/control/server/enroll" + .to_string(), + } + ); + assert_eq!( + normalize_remote_control_url("https://api.chatgpt-staging.com/backend-api") + .expect("chatgpt-staging.com subdomain URL should normalize"), + RemoteControlTarget { + websocket_url: + "wss://api.chatgpt-staging.com/backend-api/wham/remote/control/server" + .to_string(), + enroll_url: + "https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/enroll" + .to_string(), + } + ); + } + + #[test] + fn normalize_remote_control_url_accepts_localhost_urls() { + assert_eq!( + normalize_remote_control_url("http://localhost:8080/backend-api") + .expect("localhost http URL should normalize"), + RemoteControlTarget { + websocket_url: "ws://localhost:8080/backend-api/wham/remote/control/server" + .to_string(), + enroll_url: "http://localhost:8080/backend-api/wham/remote/control/server/enroll" + .to_string(), + } + ); + assert_eq!( + normalize_remote_control_url("https://localhost:8443/backend-api") + .expect("localhost https URL should normalize"), + RemoteControlTarget { + websocket_url: "wss://localhost:8443/backend-api/wham/remote/control/server" + .to_string(), + enroll_url: "https://localhost:8443/backend-api/wham/remote/control/server/enroll" + .to_string(), + } + ); + } + + #[test] + fn normalize_remote_control_url_rejects_unsupported_urls() { + for remote_control_url in [ + "http://chatgpt.com/backend-api", + "http://example.com/backend-api", + "https://example.com/backend-api", + "https://chatgpt.com.evil.com/backend-api", + "https://evilchatgpt.com/backend-api", + "https://foo.localhost/backend-api", + ] { + let err = normalize_remote_control_url(remote_control_url) + .expect_err("unsupported URL should be rejected"); + + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + format!( + "invalid remote control URL `{remote_control_url}`; expected HTTPS URL for chatgpt.com or chatgpt-staging.com, or HTTP/HTTPS URL for localhost" + ) + ); + } + } +} diff --git a/codex-rs/app-server/src/transport/remote_control/tests.rs b/codex-rs/app-server/src/transport/remote_control/tests.rs new file mode 100644 index 0000000000..9d430ccfb3 --- /dev/null +++ b/codex-rs/app-server/src/transport/remote_control/tests.rs @@ -0,0 +1,1395 @@ +use super::enroll::REMOTE_CONTROL_ACCOUNT_ID_HEADER; +use super::enroll::RemoteControlEnrollment; +use super::enroll::load_persisted_remote_control_enrollment; +use super::enroll::update_persisted_remote_control_enrollment; +use super::protocol::ClientEnvelope; +use super::protocol::ClientEvent; +use super::protocol::ClientId; +use super::protocol::normalize_remote_control_url; +use super::websocket::REMOTE_CONTROL_PROTOCOL_VERSION; +use super::*; +use crate::outgoing_message::OutgoingMessage; +use crate::outgoing_message::QueuedOutgoingMessage; +use crate::transport::CHANNEL_CAPACITY; +use crate::transport::TransportEvent; +use base64::Engine; +use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::ServerNotification; +use codex_config::types::AuthCredentialsStoreMode; +use codex_core::test_support::auth_manager_from_auth; +use codex_core::test_support::auth_manager_from_auth_with_home; +use codex_login::AuthDotJson; +use codex_login::AuthManager; +use codex_login::CodexAuth; +use codex_login::save_auth; +use codex_login::token_data::TokenData; +use codex_login::token_data::parse_chatgpt_jwt_claims; +use codex_state::StateRuntime; +use futures::SinkExt; +use futures::StreamExt; +use gethostname::gethostname; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::collections::BTreeMap; +use std::sync::Arc; +use tempfile::TempDir; +use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::io::BufReader; +use tokio::net::TcpListener; +use tokio::net::TcpStream; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::time::Duration; +use tokio::time::timeout; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::accept_async; +use tokio_tungstenite::accept_hdr_async; +use tokio_tungstenite::tungstenite; +use tokio_util::sync::CancellationToken; + +fn remote_control_auth_manager() -> Arc { + auth_manager_from_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) +} + +fn remote_control_auth_manager_with_home(codex_home: &TempDir) -> Arc { + auth_manager_from_auth_with_home( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + codex_home.path().to_path_buf(), + ) +} + +fn remote_control_auth_dot_json(account_id: Option<&str>) -> AuthDotJson { + #[derive(serde::Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = serde_json::json!({ + "email": "user@example.com", + "https://api.openai.com/auth": { + "chatgpt_user_id": "user-12345", + "user_id": "user-12345", + "chatgpt_account_id": "account_id" + } + }); + let b64 = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + let header_b64 = b64(&serde_json::to_vec(&header).expect("header should serialize")); + let payload_b64 = b64(&serde_json::to_vec(&payload).expect("payload should serialize")); + let fake_jwt = format!("{header_b64}.{payload_b64}.sig"); + + AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token: parse_chatgpt_jwt_claims(&fake_jwt).expect("fake jwt should parse"), + access_token: "Access Token".to_string(), + refresh_token: "refresh-token".to_string(), + account_id: account_id.map(str::to_string), + }), + last_refresh: Some(chrono::Utc::now()), + } +} + +async fn remote_control_state_runtime(codex_home: &TempDir) -> Arc { + StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string()) + .await + .expect("state runtime should initialize") +} + +fn remote_control_url_for_listener(listener: &TcpListener) -> String { + format!( + "http://localhost:{}/backend-api/", + listener + .local_addr() + .expect("listener should have a local addr") + .port() + ) +} + +#[tokio::test] +async fn remote_control_transport_manages_virtual_clients_and_routes_messages() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let (transport_event_tx, mut transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let (remote_task, _remote_handle) = start_remote_control( + remote_control_url, + Some(remote_control_state_runtime(&codex_home).await), + remote_control_auth_manager(), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + /*initial_enabled*/ true, + ) + .await + .expect("remote control should start"); + let enroll_request = accept_http_request(&listener).await; + assert_eq!( + enroll_request.request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + respond_with_json( + enroll_request.stream, + json!({ "server_id": "srv_e_test", "environment_id": "env_test" }), + ) + .await; + let mut websocket = accept_remote_control_connection(&listener).await; + + let client_id = ClientId("client-1".to_string()); + send_client_event( + &mut websocket, + ClientEnvelope { + event: ClientEvent::Ping, + client_id: client_id.clone(), + stream_id: None, + seq_id: None, + cursor: None, + }, + ) + .await; + assert_eq!( + read_server_event(&mut websocket).await, + json!({ + "type": "pong", + "client_id": "client-1", + "seq_id": 0, + "status": "unknown", + }) + ); + + send_client_event( + &mut websocket, + ClientEnvelope { + event: ClientEvent::ClientMessage { + message: JSONRPCMessage::Notification( + codex_app_server_protocol::JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }, + ), + }, + client_id: client_id.clone(), + stream_id: None, + seq_id: Some(0), + cursor: None, + }, + ) + .await; + assert!( + timeout(Duration::from_millis(100), transport_event_rx.recv()) + .await + .is_err(), + "non-initialize client messages should be ignored before connection creation" + ); + + let initialize_message = JSONRPCMessage::Request(codex_app_server_protocol::JSONRPCRequest { + id: codex_app_server_protocol::RequestId::Integer(1), + method: "initialize".to_string(), + params: Some(json!({ + "clientInfo": { + "name": "remote-test-client", + "version": "0.1.0" + } + })), + trace: None, + }); + send_client_event( + &mut websocket, + ClientEnvelope { + event: ClientEvent::ClientMessage { + message: initialize_message.clone(), + }, + client_id: client_id.clone(), + stream_id: None, + seq_id: Some(1), + cursor: None, + }, + ) + .await; + + let (connection_id, writer) = match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("connection open should arrive in time") + .expect("connection open should exist") + { + TransportEvent::ConnectionOpened { + connection_id, + writer, + .. + } => (connection_id, writer), + other => panic!("expected connection open event, got {other:?}"), + }; + + match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("initialize message should arrive in time") + .expect("initialize message should exist") + { + TransportEvent::IncomingMessage { + connection_id: incoming_connection_id, + message, + } => { + assert_eq!(incoming_connection_id, connection_id); + assert_eq!(message, initialize_message); + } + other => panic!("expected initialize incoming message, got {other:?}"), + } + + let followup_message = + JSONRPCMessage::Notification(codex_app_server_protocol::JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); + send_client_event( + &mut websocket, + ClientEnvelope { + event: ClientEvent::ClientMessage { + message: followup_message.clone(), + }, + client_id: client_id.clone(), + stream_id: None, + seq_id: Some(2), + cursor: None, + }, + ) + .await; + match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("followup message should arrive in time") + .expect("followup message should exist") + { + TransportEvent::IncomingMessage { + connection_id: incoming_connection_id, + message, + } => { + assert_eq!(incoming_connection_id, connection_id); + assert_eq!(message, followup_message); + } + other => panic!("expected followup incoming message, got {other:?}"), + } + + send_client_event( + &mut websocket, + ClientEnvelope { + event: ClientEvent::Ping, + client_id: client_id.clone(), + stream_id: None, + seq_id: None, + cursor: None, + }, + ) + .await; + assert_eq!( + read_server_event(&mut websocket).await, + json!({ + "type": "pong", + "client_id": "client-1", + "seq_id": 1, + "status": "active", + }) + ); + + writer + .send(QueuedOutgoingMessage::new( + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "test".to_string(), + details: None, + path: None, + range: None, + }, + )), + )) + .await + .expect("remote writer should accept outgoing message"); + assert_eq!( + read_server_event(&mut websocket).await, + json!({ + "type": "server_message", + "client_id": "client-1", + "seq_id": 2, + "message": { + "method": "configWarning", + "params": { + "summary": "test", + "details": null, + } + } + }) + ); + + send_client_event( + &mut websocket, + ClientEnvelope { + event: ClientEvent::ClientClosed, + client_id: client_id.clone(), + stream_id: None, + seq_id: None, + cursor: None, + }, + ) + .await; + match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("connection close should arrive in time") + .expect("connection close should exist") + { + TransportEvent::ConnectionClosed { + connection_id: closed_connection_id, + } => { + assert_eq!(closed_connection_id, connection_id); + } + other => panic!("expected connection close event, got {other:?}"), + } + + send_client_event( + &mut websocket, + ClientEnvelope { + event: ClientEvent::Ping, + client_id, + stream_id: None, + seq_id: None, + cursor: None, + }, + ) + .await; + assert_eq!( + read_server_event(&mut websocket).await, + json!({ + "type": "pong", + "client_id": "client-1", + "seq_id": 3, + "status": "unknown", + }) + ); + + shutdown_token.cancel(); + let _ = remote_task.await; +} + +#[tokio::test] +async fn remote_control_transport_reconnects_after_disconnect() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let (transport_event_tx, mut transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let (remote_task, _remote_handle) = start_remote_control( + remote_control_url, + Some(remote_control_state_runtime(&codex_home).await), + remote_control_auth_manager(), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + /*initial_enabled*/ true, + ) + .await + .expect("remote control should start"); + + let enroll_request = accept_http_request(&listener).await; + assert_eq!( + enroll_request.request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + respond_with_json( + enroll_request.stream, + json!({ "server_id": "srv_e_test", "environment_id": "env_test" }), + ) + .await; + let mut first_websocket = accept_remote_control_connection(&listener).await; + first_websocket + .close(None) + .await + .expect("first websocket should close"); + drop(first_websocket); + + let mut second_websocket = accept_remote_control_connection(&listener).await; + send_client_event( + &mut second_websocket, + ClientEnvelope { + event: ClientEvent::ClientMessage { + message: JSONRPCMessage::Request(codex_app_server_protocol::JSONRPCRequest { + id: codex_app_server_protocol::RequestId::Integer(2), + method: "initialize".to_string(), + params: Some(json!({ + "clientInfo": { + "name": "remote-test-client", + "version": "0.1.0" + } + })), + trace: None, + }), + }, + client_id: ClientId("client-2".to_string()), + stream_id: None, + seq_id: Some(0), + cursor: None, + }, + ) + .await; + + match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("reconnected initialize should arrive in time") + .expect("reconnected initialize should exist") + { + TransportEvent::ConnectionOpened { .. } => {} + other => panic!("expected connection open after reconnect, got {other:?}"), + } + + shutdown_token.cancel(); + let _ = remote_task.await; +} + +#[tokio::test] +async fn remote_control_start_allows_remote_control_invalid_url_when_disabled() { + let (transport_event_tx, _transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let (remote_task, _remote_handle) = start_remote_control( + "https://internal.example.com/backend-api/".to_string(), + /*state_db*/ None, + remote_control_auth_manager(), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + /*initial_enabled*/ false, + ) + .await + .expect("disabled remote control should not validate the URL at startup"); + + shutdown_token.cancel(); + timeout(Duration::from_secs(1), remote_task) + .await + .expect("remote control task should stop") + .expect("remote control task should join"); +} + +#[tokio::test] +async fn remote_control_handle_set_enabled_stops_and_restarts_connections() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let (transport_event_tx, _transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let (remote_task, remote_handle) = start_remote_control( + remote_control_url, + Some(remote_control_state_runtime(&codex_home).await), + remote_control_auth_manager(), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + /*initial_enabled*/ true, + ) + .await + .expect("remote control should start"); + + let enroll_request = accept_http_request(&listener).await; + assert_eq!( + enroll_request.request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + respond_with_json( + enroll_request.stream, + json!({ "server_id": "srv_e_test", "environment_id": "env_test" }), + ) + .await; + let mut first_websocket = accept_remote_control_connection(&listener).await; + + remote_handle.set_enabled(/*enabled*/ false); + timeout(Duration::from_secs(1), first_websocket.next()) + .await + .expect("disabling remote control should close the websocket"); + timeout(Duration::from_millis(100), listener.accept()) + .await + .expect_err("disabled remote control should not reconnect"); + + remote_handle.set_enabled(/*enabled*/ true); + let mut second_websocket = accept_remote_control_connection(&listener).await; + second_websocket + .close(None) + .await + .expect("second websocket should close"); + + shutdown_token.cancel(); + let _ = remote_task.await; +} + +#[tokio::test] +async fn remote_control_transport_clears_outgoing_buffer_when_backend_acks() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let (transport_event_tx, mut transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let (remote_task, _remote_handle) = start_remote_control( + remote_control_url, + Some(remote_control_state_runtime(&codex_home).await), + remote_control_auth_manager(), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + /*initial_enabled*/ true, + ) + .await + .expect("remote control should start"); + + let enroll_request = accept_http_request(&listener).await; + respond_with_json( + enroll_request.stream, + json!({ "server_id": "srv_e_test", "environment_id": "env_test" }), + ) + .await; + let mut first_websocket = accept_remote_control_connection(&listener).await; + + let client_id = ClientId("client-1".to_string()); + let initialize_message = JSONRPCMessage::Request(codex_app_server_protocol::JSONRPCRequest { + id: codex_app_server_protocol::RequestId::Integer(1), + method: "initialize".to_string(), + params: Some(json!({ + "clientInfo": { + "name": "remote-test-client", + "version": "0.1.0" + } + })), + trace: None, + }); + send_client_event( + &mut first_websocket, + ClientEnvelope { + event: ClientEvent::ClientMessage { + message: initialize_message, + }, + client_id: client_id.clone(), + stream_id: None, + seq_id: Some(0), + cursor: None, + }, + ) + .await; + + let writer = match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("connection open should arrive in time") + .expect("connection open should exist") + { + TransportEvent::ConnectionOpened { writer, .. } => writer, + other => panic!("expected connection open event, got {other:?}"), + }; + match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("initialize message should arrive in time") + .expect("initialize message should exist") + { + TransportEvent::IncomingMessage { .. } => {} + other => panic!("expected initialize incoming message, got {other:?}"), + } + + writer + .send(QueuedOutgoingMessage::new( + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "stale".to_string(), + details: None, + path: None, + range: None, + }, + )), + )) + .await + .expect("remote writer should accept outgoing message"); + assert_eq!( + read_server_event(&mut first_websocket).await, + json!({ + "type": "server_message", + "client_id": "client-1", + "seq_id": 0, + "message": { + "method": "configWarning", + "params": { + "summary": "stale", + "details": null, + } + } + }) + ); + + send_client_event( + &mut first_websocket, + ClientEnvelope { + event: ClientEvent::Ack, + client_id: client_id.clone(), + stream_id: None, + seq_id: Some(0), + cursor: None, + }, + ) + .await; + + send_client_event( + &mut first_websocket, + ClientEnvelope { + event: ClientEvent::ClientClosed, + client_id: client_id.clone(), + stream_id: None, + seq_id: None, + cursor: None, + }, + ) + .await; + match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("connection close should arrive in time") + .expect("connection close should exist") + { + TransportEvent::ConnectionClosed { .. } => {} + other => panic!("expected connection close event, got {other:?}"), + } + + first_websocket + .close(None) + .await + .expect("first websocket should close"); + drop(first_websocket); + + let mut second_websocket = accept_remote_control_connection(&listener).await; + send_client_event( + &mut second_websocket, + ClientEnvelope { + event: ClientEvent::Ping, + client_id, + stream_id: None, + seq_id: None, + cursor: None, + }, + ) + .await; + assert_eq!( + read_server_event(&mut second_websocket).await, + json!({ + "type": "pong", + "client_id": "client-1", + "seq_id": 1, + "status": "unknown", + }) + ); + + shutdown_token.cancel(); + let _ = remote_task.await; +} + +#[tokio::test] +async fn remote_control_http_mode_enrolls_before_connecting() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let (transport_event_tx, mut transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let expected_server_name = gethostname().to_string_lossy().trim().to_string(); + let shutdown_token = CancellationToken::new(); + let (remote_task, _remote_handle) = start_remote_control( + remote_control_url, + Some(remote_control_state_runtime(&codex_home).await), + remote_control_auth_manager(), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + /*initial_enabled*/ true, + ) + .await + .expect("remote control should start"); + + let enroll_request = accept_http_request(&listener).await; + assert_eq!( + enroll_request.request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + assert_eq!( + enroll_request.headers.get("authorization"), + Some(&"Bearer Access Token".to_string()) + ); + assert_eq!( + enroll_request.headers.get(REMOTE_CONTROL_ACCOUNT_ID_HEADER), + Some(&"account_id".to_string()) + ); + assert_eq!( + serde_json::from_str::(&enroll_request.body) + .expect("enroll body should deserialize"), + json!({ + "name": expected_server_name, + "os": std::env::consts::OS, + "arch": std::env::consts::ARCH, + "app_server_version": env!("CARGO_PKG_VERSION"), + }) + ); + respond_with_json( + enroll_request.stream, + json!({ "server_id": "srv_e_test", "environment_id": "env_test" }), + ) + .await; + + let (handshake_request, mut websocket) = + accept_remote_control_backend_connection(&listener).await; + assert_eq!( + handshake_request.path, + "/backend-api/wham/remote/control/server" + ); + assert_eq!( + handshake_request.headers.get("authorization"), + Some(&"Bearer Access Token".to_string()) + ); + assert_eq!( + handshake_request + .headers + .get(REMOTE_CONTROL_ACCOUNT_ID_HEADER), + Some(&"account_id".to_string()) + ); + assert_eq!( + handshake_request.headers.get("x-codex-server-id"), + Some(&"srv_e_test".to_string()) + ); + assert_eq!( + handshake_request.headers.get("x-codex-name"), + Some(&base64::engine::general_purpose::STANDARD.encode(&expected_server_name)) + ); + assert_eq!( + handshake_request.headers.get("x-codex-protocol-version"), + Some(&REMOTE_CONTROL_PROTOCOL_VERSION.to_string()) + ); + + let backend_client_id = ClientId("backend-test-client".to_string()); + let writer = { + let initialize_message = + JSONRPCMessage::Request(codex_app_server_protocol::JSONRPCRequest { + id: codex_app_server_protocol::RequestId::Integer(11), + method: "initialize".to_string(), + params: Some(json!({ + "clientInfo": { + "name": "remote-backend-client", + "version": "0.1.0" + } + })), + trace: None, + }); + send_client_event( + &mut websocket, + ClientEnvelope { + event: ClientEvent::ClientMessage { + message: initialize_message.clone(), + }, + client_id: backend_client_id.clone(), + stream_id: None, + seq_id: Some(0), + cursor: None, + }, + ) + .await; + + let (connection_id, writer) = + match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("connection open should arrive in time") + .expect("connection open should exist") + { + TransportEvent::ConnectionOpened { + connection_id, + writer, + .. + } => (connection_id, writer), + other => panic!("expected connection open event, got {other:?}"), + }; + + match timeout(Duration::from_secs(5), transport_event_rx.recv()) + .await + .expect("initialize message should arrive in time") + .expect("initialize message should exist") + { + TransportEvent::IncomingMessage { + connection_id: incoming_connection_id, + message, + } => { + assert_eq!(incoming_connection_id, connection_id); + assert_eq!(message, initialize_message); + } + other => panic!("expected initialize incoming message, got {other:?}"), + } + writer + }; + + writer + .send(QueuedOutgoingMessage::new(OutgoingMessage::Response( + crate::outgoing_message::OutgoingResponse { + id: codex_app_server_protocol::RequestId::Integer(11), + result: json!({ + "userAgent": "codex-test-agent" + }), + }, + ))) + .await + .expect("remote writer should accept initialize response"); + assert_eq!( + read_server_event(&mut websocket).await, + json!({ + "type": "server_message", + "client_id": backend_client_id.0.clone(), + "seq_id": 0, + "message": { + "id": 11, + "result": { + "userAgent": "codex-test-agent", + } + } + }) + ); + + writer + .send(QueuedOutgoingMessage::new( + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "backend".to_string(), + details: None, + path: None, + range: None, + }, + )), + )) + .await + .expect("remote writer should accept outgoing message"); + assert_eq!( + read_server_event(&mut websocket).await, + json!({ + "type": "server_message", + "client_id": backend_client_id.0.clone(), + "seq_id": 1, + "message": { + "method": "configWarning", + "params": { + "summary": "backend", + "details": null, + } + } + }) + ); + + shutdown_token.cancel(); + let _ = remote_task.await; +} + +#[tokio::test] +async fn remote_control_http_mode_reuses_persisted_enrollment_before_reenrolling() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let persisted_enrollment = RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_persisted".to_string(), + server_id: "srv_e_persisted".to_string(), + server_name: "persisted-server".to_string(), + }; + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + Some(&persisted_enrollment), + ) + .await + .expect("persisted enrollment should save"); + + let (transport_event_tx, _transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let (remote_task, _remote_handle) = start_remote_control( + remote_control_url, + Some(state_db.clone()), + remote_control_auth_manager_with_home(&codex_home), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + /*initial_enabled*/ true, + ) + .await + .expect("remote control should start"); + + let (handshake_request, _websocket) = accept_remote_control_backend_connection(&listener).await; + assert_eq!( + handshake_request.path, + "/backend-api/wham/remote/control/server" + ); + assert_eq!( + handshake_request.headers.get("x-codex-server-id"), + Some(&persisted_enrollment.server_id) + ); + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + ) + .await, + Some(persisted_enrollment) + ); + + shutdown_token.cancel(); + let _ = remote_task.await; +} + +#[tokio::test] +async fn remote_control_stdio_mode_waits_for_client_name_before_connecting() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let app_server_client_name = "stdio-client"; + let persisted_enrollment = RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_persisted".to_string(), + server_id: "srv_e_persisted".to_string(), + server_name: "persisted-server".to_string(), + }; + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + Some(app_server_client_name), + Some(&persisted_enrollment), + ) + .await + .expect("persisted enrollment should save"); + + let (transport_event_tx, _transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let (app_server_client_name_tx, app_server_client_name_rx) = oneshot::channel::(); + let shutdown_token = CancellationToken::new(); + let (remote_task, _remote_handle) = start_remote_control( + remote_control_url, + Some(state_db.clone()), + remote_control_auth_manager_with_home(&codex_home), + transport_event_tx, + shutdown_token.clone(), + Some(app_server_client_name_rx), + /*initial_enabled*/ true, + ) + .await + .expect("remote control should start"); + + timeout(Duration::from_millis(100), listener.accept()) + .await + .expect_err("remote control should wait for the stdio client name"); + + let _ = app_server_client_name_tx.send(app_server_client_name.to_string()); + let (handshake_request, _websocket) = accept_remote_control_backend_connection(&listener).await; + assert_eq!( + handshake_request.headers.get("x-codex-server-id"), + Some(&persisted_enrollment.server_id) + ); + + shutdown_token.cancel(); + let _ = remote_task.await; +} + +#[tokio::test] +async fn remote_control_waits_for_account_id_before_enrolling() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + save_auth( + codex_home.path(), + &remote_control_auth_dot_json(/*account_id*/ None), + AuthCredentialsStoreMode::File, + ) + .expect("auth without account id should save"); + let state_db = remote_control_state_runtime(&codex_home).await; + let auth_manager = AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + ); + let expected_server_name = gethostname().to_string_lossy().trim().to_string(); + let expected_enrollment = RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_ready".to_string(), + server_id: "srv_e_ready".to_string(), + server_name: expected_server_name, + }; + + let (transport_event_tx, _transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let (remote_task, _remote_handle) = start_remote_control( + remote_control_url, + Some(state_db.clone()), + auth_manager, + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + /*initial_enabled*/ true, + ) + .await + .expect("remote control should start before account id is available"); + + timeout(Duration::from_millis(100), listener.accept()) + .await + .expect_err("remote control should wait for account id before enrolling"); + + save_auth( + codex_home.path(), + &remote_control_auth_dot_json(Some("account_id")), + AuthCredentialsStoreMode::File, + ) + .expect("auth with account id should save"); + + let enroll_request = accept_http_request(&listener).await; + assert_eq!( + enroll_request.request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + respond_with_json( + enroll_request.stream, + json!({ + "server_id": expected_enrollment.server_id, + "environment_id": expected_enrollment.environment_id, + }), + ) + .await; + + let (handshake_request, _websocket) = accept_remote_control_backend_connection(&listener).await; + assert_eq!( + handshake_request.headers.get("x-codex-server-id"), + Some(&expected_enrollment.server_id) + ); + + shutdown_token.cancel(); + let _ = remote_task.await; +} + +#[tokio::test] +async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let expected_server_name = gethostname().to_string_lossy().trim().to_string(); + let stale_enrollment = RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_stale".to_string(), + server_id: "srv_e_stale".to_string(), + server_name: "stale-server".to_string(), + }; + let refreshed_enrollment = RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_refreshed".to_string(), + server_id: "srv_e_refreshed".to_string(), + server_name: expected_server_name, + }; + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + Some(&stale_enrollment), + ) + .await + .expect("stale enrollment should save"); + + let (transport_event_tx, _transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let (remote_task, _remote_handle) = start_remote_control( + remote_control_url, + Some(state_db.clone()), + remote_control_auth_manager_with_home(&codex_home), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + /*initial_enabled*/ true, + ) + .await + .expect("remote control should start"); + + let websocket_request = accept_http_request(&listener).await; + assert_eq!( + websocket_request.request_line, + "GET /backend-api/wham/remote/control/server HTTP/1.1" + ); + assert_eq!( + websocket_request.headers.get("x-codex-server-id"), + Some(&stale_enrollment.server_id) + ); + respond_with_status(websocket_request.stream, "404 Not Found", "").await; + + let enroll_request = accept_http_request(&listener).await; + assert_eq!( + enroll_request.request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + respond_with_json( + enroll_request.stream, + json!({ + "server_id": refreshed_enrollment.server_id, + "environment_id": refreshed_enrollment.environment_id, + }), + ) + .await; + + let (handshake_request, _websocket) = accept_remote_control_backend_connection(&listener).await; + assert_eq!( + handshake_request.headers.get("x-codex-server-id"), + Some(&refreshed_enrollment.server_id) + ); + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + ) + .await, + Some(refreshed_enrollment) + ); + + shutdown_token.cancel(); + let _ = remote_task.await; +} + +#[derive(Debug)] +struct CapturedHttpRequest { + stream: TcpStream, + request_line: String, + headers: BTreeMap, + body: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct CapturedWebSocketRequest { + path: String, + headers: BTreeMap, +} + +async fn accept_remote_control_connection(listener: &TcpListener) -> WebSocketStream { + let (stream, _) = timeout(Duration::from_secs(5), listener.accept()) + .await + .expect("remote control should connect in time") + .expect("listener accept should succeed"); + accept_async(stream) + .await + .expect("websocket handshake should succeed") +} + +async fn accept_http_request(listener: &TcpListener) -> CapturedHttpRequest { + let (stream, _) = timeout(Duration::from_secs(5), listener.accept()) + .await + .expect("HTTP request should arrive in time") + .expect("listener accept should succeed"); + let mut reader = BufReader::new(stream); + + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .await + .expect("request line should read"); + let request_line = request_line.trim_end_matches("\r\n").to_string(); + + let mut headers = BTreeMap::new(); + loop { + let mut line = String::new(); + reader + .read_line(&mut line) + .await + .expect("header line should read"); + if line == "\r\n" { + break; + } + let line = line.trim_end_matches("\r\n"); + let (name, value) = line.split_once(':').expect("header should contain colon"); + headers.insert(name.to_ascii_lowercase(), value.trim().to_string()); + } + + let content_length = headers + .get("content-length") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let mut body = vec![0; content_length]; + reader + .read_exact(&mut body) + .await + .expect("request body should read"); + + CapturedHttpRequest { + stream: reader.into_inner(), + request_line, + headers, + body: String::from_utf8(body).expect("body should be utf-8"), + } +} + +async fn respond_with_json(mut stream: TcpStream, body: serde_json::Value) { + let body = body.to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .await + .expect("response should write"); + stream.flush().await.expect("response should flush"); +} + +async fn respond_with_status(stream: TcpStream, status: &str, body: &str) { + respond_with_status_and_headers(stream, status, &[], body).await; +} + +async fn respond_with_status_and_headers( + mut stream: TcpStream, + status: &str, + headers: &[(&str, &str)], + body: &str, +) { + let extra_headers = headers + .iter() + .map(|(name, value)| format!("{name}: {value}\r\n")) + .collect::(); + let response = format!( + "HTTP/1.1 {status}\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n{extra_headers}\r\n{body}", + body.len(), + ); + stream + .write_all(response.as_bytes()) + .await + .expect("response should write"); + stream.flush().await.expect("response should flush"); +} + +async fn accept_remote_control_backend_connection( + listener: &TcpListener, +) -> (CapturedWebSocketRequest, WebSocketStream) { + let (stream, _) = timeout(Duration::from_secs(5), listener.accept()) + .await + .expect("websocket request should arrive in time") + .expect("listener accept should succeed"); + let captured_request = Arc::new(std::sync::Mutex::new(None::)); + let captured_request_for_callback = captured_request.clone(); + let websocket = accept_hdr_async( + stream, + move |request: &tungstenite::handshake::server::Request, + response: tungstenite::handshake::server::Response| { + let headers = request + .headers() + .iter() + .map(|(name, value)| { + ( + name.as_str().to_ascii_lowercase(), + value + .to_str() + .expect("header should be valid utf-8") + .to_string(), + ) + }) + .collect::>(); + *captured_request_for_callback + .lock() + .expect("capture lock should acquire") = Some(CapturedWebSocketRequest { + path: request.uri().path().to_string(), + headers, + }); + Ok(response) + }, + ) + .await + .expect("websocket handshake should succeed"); + let captured_request = captured_request + .lock() + .expect("capture lock should acquire") + .clone() + .expect("websocket request should be captured"); + (captured_request, websocket) +} + +async fn send_client_event( + websocket: &mut WebSocketStream, + client_envelope: ClientEnvelope, +) { + let payload = serde_json::to_string(&client_envelope).expect("client event should serialize"); + websocket + .send(tungstenite::Message::Text(payload.into())) + .await + .expect("client event should send"); +} + +async fn read_server_event(websocket: &mut WebSocketStream) -> serde_json::Value { + loop { + let frame = timeout(Duration::from_secs(5), websocket.next()) + .await + .expect("server event should arrive in time") + .expect("websocket should stay open") + .expect("websocket frame should be readable"); + match frame { + tungstenite::Message::Text(text) => { + let mut event: serde_json::Value = + serde_json::from_str(text.as_ref()).expect("server event should deserialize"); + if let Some(stream_id) = event + .as_object_mut() + .and_then(|event| event.remove("stream_id")) + { + assert!(stream_id.is_string(), "stream_id should be a string"); + } + return event; + } + tungstenite::Message::Ping(payload) => { + websocket + .send(tungstenite::Message::Pong(payload)) + .await + .expect("websocket pong should send"); + } + tungstenite::Message::Pong(_) => {} + tungstenite::Message::Close(frame) => { + panic!("unexpected websocket close frame: {frame:?}"); + } + tungstenite::Message::Binary(_) => { + panic!("unexpected binary websocket frame"); + } + tungstenite::Message::Frame(_) => {} + } + } +} diff --git a/codex-rs/app-server/src/transport/remote_control/websocket.rs b/codex-rs/app-server/src/transport/remote_control/websocket.rs new file mode 100644 index 0000000000..a0387ef6c3 --- /dev/null +++ b/codex-rs/app-server/src/transport/remote_control/websocket.rs @@ -0,0 +1,1474 @@ +use crate::transport::TransportEvent; +use crate::transport::remote_control::client_tracker::ClientTracker; +use crate::transport::remote_control::client_tracker::REMOTE_CONTROL_IDLE_SWEEP_INTERVAL; +use crate::transport::remote_control::enroll::RemoteControlConnectionAuth; +use crate::transport::remote_control::enroll::RemoteControlEnrollment; +use crate::transport::remote_control::enroll::enroll_remote_control_server; +use crate::transport::remote_control::enroll::format_headers; +use crate::transport::remote_control::enroll::load_persisted_remote_control_enrollment; +use crate::transport::remote_control::enroll::preview_remote_control_response_body; +use crate::transport::remote_control::enroll::update_persisted_remote_control_enrollment; + +use super::protocol::ClientEnvelope; +use super::protocol::ClientEvent; +use super::protocol::ClientId; +use super::protocol::RemoteControlTarget; +use super::protocol::ServerEnvelope; +use axum::http::HeaderValue; +use base64::Engine; +use codex_core::util::backoff; +use codex_login::AuthManager; +use codex_login::UnauthorizedRecovery; +use codex_state::StateRuntime; +use codex_utils_rustls_provider::ensure_rustls_crypto_provider; +use futures::SinkExt; +use futures::StreamExt; +use futures::stream::SplitSink; +use futures::stream::SplitStream; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::io; +use std::io::ErrorKind; +use std::sync::Arc; +use tokio::net::TcpStream; +use tokio::sync::Mutex; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::sync::watch; +use tokio::time::MissedTickBehavior; +use tokio_tungstenite::MaybeTlsStream; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_util::sync::CancellationToken; +use tracing::error; +use tracing::info; +use tracing::warn; + +pub(super) const REMOTE_CONTROL_PROTOCOL_VERSION: &str = "2"; +pub(super) const REMOTE_CONTROL_ACCOUNT_ID_HEADER: &str = "chatgpt-account-id"; +const REMOTE_CONTROL_SUBSCRIBE_CURSOR_HEADER: &str = "x-codex-subscribe-cursor"; +const REMOTE_CONTROL_WEBSOCKET_PING_INTERVAL: std::time::Duration = + std::time::Duration::from_secs(10); +const REMOTE_CONTROL_WEBSOCKET_PONG_TIMEOUT: std::time::Duration = + std::time::Duration::from_secs(60); +const REMOTE_CONTROL_ACCOUNT_ID_RETRY_INTERVAL: std::time::Duration = + std::time::Duration::from_secs(1); + +struct BoundedOutboundBuffer { + // Remote-control acks are generated by the backend at client scope, so + // retransmit retention is keyed by client_id only. stream_id stays on each + // envelope for routing, but it is not part of the ack cursor. + buffer_by_client: HashMap>, + used_tx: watch::Sender, +} + +impl BoundedOutboundBuffer { + fn new() -> (Self, watch::Receiver) { + let (used_tx, used_rx) = watch::channel(0); + let buffer = Self { + buffer_by_client: HashMap::new(), + used_tx, + }; + (buffer, used_rx) + } + + fn insert(&mut self, server_envelope: &ServerEnvelope) { + self.buffer_by_client + .entry(server_envelope.client_id.clone()) + .or_default() + .insert(server_envelope.seq_id, server_envelope.clone()); + self.used_tx.send_modify(|used| *used += 1); + } + + fn ack(&mut self, client_id: &ClientId, acked_seq_id: u64) { + let Some(buffer) = self.buffer_by_client.get_mut(client_id) else { + return; + }; + while let Some(seq_id) = buffer.first_key_value().map(|(seq_id, _)| seq_id) + && *seq_id <= acked_seq_id + { + buffer.pop_first(); + self.used_tx.send_modify(|used| *used -= 1); + } + if buffer.is_empty() { + self.buffer_by_client.remove(client_id); + } + } + + fn server_envelopes(&self) -> impl Iterator { + self.buffer_by_client + .values() + .flat_map(|buffer| buffer.values()) + } +} + +struct WebsocketState { + outbound_buffer: BoundedOutboundBuffer, + subscribe_cursor: Option, + next_seq_id: u64, +} + +pub(crate) struct RemoteControlWebsocket { + remote_control_url: String, + remote_control_target: Option, + state_db: Option>, + auth_manager: Arc, + shutdown_token: CancellationToken, + reconnect_attempt: u64, + enrollment: Option, + auth_recovery: UnauthorizedRecovery, + client_tracker: Arc>, + state: Arc>, + server_event_rx: Arc>>, + used_rx: watch::Receiver, + enabled_rx: watch::Receiver, +} + +enum ConnectOutcome { + Connected(Box>>), + Disabled, + Shutdown, +} + +impl RemoteControlWebsocket { + pub(crate) fn new( + remote_control_url: String, + remote_control_target: Option, + state_db: Option>, + auth_manager: Arc, + transport_event_tx: mpsc::Sender, + shutdown_token: CancellationToken, + enabled_rx: watch::Receiver, + ) -> Self { + let shutdown_token = shutdown_token.child_token(); + let (server_event_tx, server_event_rx) = mpsc::channel(super::CHANNEL_CAPACITY); + let client_tracker = + ClientTracker::new(server_event_tx, transport_event_tx, &shutdown_token); + let (outbound_buffer, used_rx) = BoundedOutboundBuffer::new(); + let auth_recovery = auth_manager.unauthorized_recovery(); + + Self { + remote_control_url, + remote_control_target, + state_db, + auth_manager, + shutdown_token, + reconnect_attempt: 0, + enrollment: None, + auth_recovery, + client_tracker: Arc::new(Mutex::new(client_tracker)), + state: Arc::new(Mutex::new(WebsocketState { + outbound_buffer, + subscribe_cursor: None, + next_seq_id: 0, + })), + server_event_rx: Arc::new(Mutex::new(server_event_rx)), + used_rx, + enabled_rx, + } + } + + pub(crate) async fn run( + mut self, + app_server_client_name_rx: Option>, + ) { + let app_server_client_name = match self + .wait_for_app_server_client_name(app_server_client_name_rx) + .await + { + Ok(app_server_client_name) => app_server_client_name, + Err(_) => { + self.client_tracker.lock().await.shutdown().await; + return; + } + }; + + loop { + if !self.wait_until_enabled().await { + break; + } + + let shutdown_token = self.shutdown_token.child_token(); + let websocket_connection = match self + .connect(&shutdown_token, app_server_client_name.as_deref()) + .await + { + ConnectOutcome::Connected(websocket_connection) => *websocket_connection, + ConnectOutcome::Disabled => continue, + ConnectOutcome::Shutdown => break, + }; + + self.run_connection(websocket_connection, shutdown_token) + .await; + } + + self.client_tracker.lock().await.shutdown().await; + } + + async fn wait_for_app_server_client_name( + &self, + app_server_client_name_rx: Option>, + ) -> Result, ()> { + match app_server_client_name_rx { + Some(app_server_client_name_rx) => { + tokio::select! { + _ = self.shutdown_token.cancelled() => Err(()), + app_server_client_name = app_server_client_name_rx => match app_server_client_name { + Ok(app_server_client_name) => Ok(Some(app_server_client_name)), + Err(_) => Err(()), + }, + } + } + None => Ok(None), + } + } + + async fn wait_until_enabled(&mut self) -> bool { + tokio::select! { + _ = self.shutdown_token.cancelled() => false, + enabled = self.enabled_rx.wait_for(|enabled| *enabled) => enabled.is_ok(), + } + } + + async fn connect( + &mut self, + shutdown_token: &CancellationToken, + app_server_client_name: Option<&str>, + ) -> ConnectOutcome { + let remote_control_target = match self.remote_control_target.as_ref() { + Some(remote_control_target) => remote_control_target.clone(), + None => match super::protocol::normalize_remote_control_url(&self.remote_control_url) { + Ok(remote_control_target) => { + self.remote_control_target = Some(remote_control_target.clone()); + remote_control_target + } + Err(err) => { + warn!("remote control is enabled but the URL is invalid: {err}"); + tokio::select! { + _ = shutdown_token.cancelled() => return ConnectOutcome::Shutdown, + changed = self.enabled_rx.wait_for(|enabled| !*enabled) => { + if changed.is_err() { + return ConnectOutcome::Shutdown; + } + return ConnectOutcome::Disabled; + } + } + } + }, + }; + + loop { + let subscribe_cursor = self.state.lock().await.subscribe_cursor.clone(); + let connect_result = tokio::select! { + _ = shutdown_token.cancelled() => return ConnectOutcome::Shutdown, + changed = self.enabled_rx.wait_for(|enabled| !*enabled) => { + if changed.is_err() { + return ConnectOutcome::Shutdown; + } + return ConnectOutcome::Disabled; + } + connect_result = connect_remote_control_websocket( + &remote_control_target, + self.state_db.as_deref(), + &self.auth_manager, + &mut self.auth_recovery, + &mut self.enrollment, + subscribe_cursor.as_deref(), + app_server_client_name, + ) => connect_result, + }; + + match connect_result { + Ok((websocket_connection, response)) => { + self.reconnect_attempt = 0; + self.auth_recovery = self.auth_manager.unauthorized_recovery(); + info!( + "connected to app-server remote control websocket: {}, {}", + remote_control_target.websocket_url, + format_headers(response.headers()) + ); + return ConnectOutcome::Connected(Box::new(websocket_connection)); + } + Err(err) => { + let reconnect_delay = if err.kind() == ErrorKind::WouldBlock { + REMOTE_CONTROL_ACCOUNT_ID_RETRY_INTERVAL + } else { + warn!( + "failed to connect to app-server remote control websocket: {}, err: {}", + remote_control_target.websocket_url, err + ); + let reconnect_delay = backoff(self.reconnect_attempt); + self.reconnect_attempt += 1; + reconnect_delay + }; + tokio::select! { + _ = shutdown_token.cancelled() => return ConnectOutcome::Shutdown, + changed = self.enabled_rx.wait_for(|enabled| !*enabled) => { + if changed.is_err() { + return ConnectOutcome::Shutdown; + } + return ConnectOutcome::Disabled; + } + _ = tokio::time::sleep(reconnect_delay) => {} + } + } + } + } + } + + async fn run_connection( + &self, + websocket_connection: WebSocketStream>, + shutdown_token: CancellationToken, + ) { + let (websocket_writer, websocket_reader) = websocket_connection.split(); + let mut join_set = tokio::task::JoinSet::new(); + + join_set.spawn(Self::run_server_writer( + self.state.clone(), + self.server_event_rx.clone(), + self.used_rx.clone(), + websocket_writer, + REMOTE_CONTROL_WEBSOCKET_PING_INTERVAL, + shutdown_token.clone(), + )); + join_set.spawn(Self::run_websocket_reader( + self.client_tracker.clone(), + self.state.clone(), + websocket_reader, + REMOTE_CONTROL_WEBSOCKET_PONG_TIMEOUT, + shutdown_token.clone(), + )); + + let mut enabled_rx = self.enabled_rx.clone(); + tokio::select! { + _ = shutdown_token.cancelled() => {} + _ = enabled_rx.wait_for(|enabled| !*enabled) => shutdown_token.cancel(), + _ = join_set.join_next() => shutdown_token.cancel(), + } + + join_set.join_all().await; + } + + async fn run_server_writer( + state: Arc>, + server_event_rx: Arc>>, + used_rx: watch::Receiver, + websocket_writer: SplitSink< + WebSocketStream>, + tungstenite::Message, + >, + ping_interval: std::time::Duration, + shutdown_token: CancellationToken, + ) { + let result = Self::run_server_writer_inner( + state, + server_event_rx, + used_rx, + websocket_writer, + ping_interval, + shutdown_token, + ) + .await; + if let Err(err) = result { + warn!("remote control websocket writer disconnected, err: {err}"); + } else { + warn!("remote control websocket writer was stopped"); + } + } + + async fn run_server_writer_inner( + state: Arc>, + server_event_rx: Arc>>, + mut used_rx: watch::Receiver, + mut websocket_writer: SplitSink< + WebSocketStream>, + tungstenite::Message, + >, + ping_interval: std::time::Duration, + shutdown_token: CancellationToken, + ) -> io::Result<()> { + for server_envelope in state.lock().await.outbound_buffer.server_envelopes() { + let payload = match serde_json::to_string(&server_envelope) { + Ok(payload) => payload, + Err(err) => { + error!("failed to serialize remote-control server event: {err}"); + continue; + } + }; + tokio::select! { + _ = shutdown_token.cancelled() => return Ok(()), + send_result = websocket_writer.send(tungstenite::Message::Text(payload.into())) => { + if let Err(err) = send_result { + return Err(io::Error::other(err)); + } + } + }; + } + + let mut ping_interval = + tokio::time::interval_at(tokio::time::Instant::now() + ping_interval, ping_interval); + ping_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + + let mut server_event_rx = server_event_rx.lock().await; + loop { + let outbound_has_capacity = *used_rx.borrow() < super::CHANNEL_CAPACITY; + let queued_server_envelope = tokio::select! { + _ = shutdown_token.cancelled() => return Ok(()), + _ = ping_interval.tick() => { + if let Err(err) = websocket_writer + .send(tungstenite::Message::Ping(Vec::new().into())) + .await + { + return Err(io::Error::other(err)); + } + continue; + } + wait_result = used_rx.changed(), if !outbound_has_capacity => + { + if wait_result.is_err() { + return Err(io::Error::new( + ErrorKind::UnexpectedEof, + "outbound buffer usage channel closed", + )); + } + continue; + } + recv_result = server_event_rx.recv(), if outbound_has_capacity => { + match recv_result { + Some(queued_server_envelope) => queued_server_envelope, + None => { + return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "server event channel closed")); + } + } + } + }; + let (server_envelope, write_complete_tx) = { + let mut state = state.lock().await; + let seq_id = state.next_seq_id; + state.next_seq_id = state.next_seq_id.saturating_add(1); + + let server_envelope = ServerEnvelope { + event: queued_server_envelope.event, + client_id: queued_server_envelope.client_id, + seq_id, + stream_id: queued_server_envelope.stream_id, + }; + state.outbound_buffer.insert(&server_envelope); + + (server_envelope, queued_server_envelope.write_complete_tx) + }; + + let payload = match serde_json::to_string(&server_envelope) { + Ok(payload) => payload, + Err(err) => { + error!("failed to serialize remote-control server event: {err}"); + continue; + } + }; + + tokio::select! { + _ = shutdown_token.cancelled() => return Ok(()), + send_result = websocket_writer.send(tungstenite::Message::Text(payload.into())) => { + if let Err(err) = send_result { + return Err(io::Error::other(err)); + } + } + }; + if let Some(write_complete_tx) = write_complete_tx { + let _ = write_complete_tx.send(()); + } + } + } + + async fn run_websocket_reader( + client_tracker: Arc>, + state: Arc>, + websocket_reader: SplitStream>>, + pong_timeout: std::time::Duration, + shutdown_token: CancellationToken, + ) { + let result = Self::run_websocket_reader_inner( + client_tracker, + state, + websocket_reader, + pong_timeout, + shutdown_token, + ) + .await; + if let Err(err) = result { + warn!("remote control websocket reader disconnected, err: {err}"); + } else { + warn!("remote control websocket reader was stopped"); + } + } + + async fn run_websocket_reader_inner( + client_tracker: Arc>, + state: Arc>, + mut websocket_reader: SplitStream>>, + pong_timeout: std::time::Duration, + shutdown_token: CancellationToken, + ) -> io::Result<()> { + let mut client_tracker = client_tracker.lock().await; + let mut idle_sweep_interval = tokio::time::interval(REMOTE_CONTROL_IDLE_SWEEP_INTERVAL); + idle_sweep_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + let pong_deadline = tokio::time::sleep(pong_timeout); + tokio::pin!(pong_deadline); + + loop { + let incoming_message = tokio::select! { + _ = shutdown_token.cancelled() => return Ok(()), + _ = &mut pong_deadline => { + return Err(io::Error::new( + ErrorKind::TimedOut, + "remote control websocket pong timeout", + )); + } + client_key = client_tracker.bookkeep_join_set() => { + let Some(client_key) = client_key else { + continue; + }; + if client_tracker.close_client(&client_key).await.is_err() { + return Ok(()); + } + continue; + } + _ = idle_sweep_interval.tick() => { + if client_tracker.close_expired_clients().await.is_err() { + return Ok(()); + } + continue; + } + incoming_message = websocket_reader.next() => { + match incoming_message { + Some(incoming_message) => incoming_message, + None => return Err(io::Error::new(ErrorKind::UnexpectedEof, "websocket stream ended")), + } + } + }; + let client_envelope = match incoming_message { + Ok(tungstenite::Message::Text(text)) => { + match serde_json::from_str::(&text) { + Ok(client_envelope) => client_envelope, + Err(err) => { + warn!("failed to deserialize remote-control client event: {err}"); + continue; + } + } + } + Ok(tungstenite::Message::Pong(_)) => { + pong_deadline + .as_mut() + .reset(tokio::time::Instant::now() + pong_timeout); + continue; + } + Ok(tungstenite::Message::Ping(_)) | Ok(tungstenite::Message::Frame(_)) => continue, + Ok(tungstenite::Message::Binary(_)) => { + warn!("dropping unsupported binary remote-control websocket message"); + continue; + } + Ok(tungstenite::Message::Close(_)) => { + return Err(io::Error::new( + ErrorKind::ConnectionAborted, + "websocket disconnected", + )); + } + Err(err) => { + return Err(io::Error::new( + ErrorKind::InvalidData, + format!("failed to read from websocket: {err}"), + )); + } + }; + + let mut websocket_state = state.lock().await; + if let Some(cursor) = client_envelope.cursor.as_deref() { + websocket_state.subscribe_cursor = Some(cursor.to_string()); + } + if let ClientEvent::Ack = &client_envelope.event + && let Some(acked_seq_id) = client_envelope.seq_id + { + websocket_state + .outbound_buffer + .ack(&client_envelope.client_id, acked_seq_id); + } + drop(websocket_state); + + if client_tracker + .handle_message(client_envelope) + .await + .is_err() + { + return Ok(()); + } + } + } +} + +fn set_remote_control_header( + headers: &mut tungstenite::http::HeaderMap, + name: &'static str, + value: &str, +) -> io::Result<()> { + let header_value = HeaderValue::from_str(value).map_err(|err| { + io::Error::new( + ErrorKind::InvalidInput, + format!("invalid remote control header `{name}`: {err}"), + ) + })?; + headers.insert(name, header_value); + Ok(()) +} + +fn build_remote_control_websocket_request( + websocket_url: &str, + enrollment: &RemoteControlEnrollment, + auth: &RemoteControlConnectionAuth, + subscribe_cursor: Option<&str>, +) -> io::Result> { + let mut request = websocket_url.into_client_request().map_err(|err| { + io::Error::new( + ErrorKind::InvalidInput, + format!("invalid remote control websocket URL `{websocket_url}`: {err}"), + ) + })?; + let headers = request.headers_mut(); + set_remote_control_header(headers, "x-codex-server-id", &enrollment.server_id)?; + set_remote_control_header( + headers, + "x-codex-name", + &base64::engine::general_purpose::STANDARD.encode(&enrollment.server_name), + )?; + set_remote_control_header( + headers, + "x-codex-protocol-version", + REMOTE_CONTROL_PROTOCOL_VERSION, + )?; + set_remote_control_header( + headers, + "authorization", + &format!("Bearer {}", auth.bearer_token), + )?; + set_remote_control_header(headers, REMOTE_CONTROL_ACCOUNT_ID_HEADER, &auth.account_id)?; + if let Some(subscribe_cursor) = subscribe_cursor { + set_remote_control_header( + headers, + REMOTE_CONTROL_SUBSCRIBE_CURSOR_HEADER, + subscribe_cursor, + )?; + } + Ok(request) +} + +pub(crate) async fn load_remote_control_auth( + auth_manager: &Arc, +) -> io::Result { + let mut reloaded = false; + let auth = loop { + let Some(auth) = auth_manager.auth().await else { + if reloaded { + return Err(io::Error::new( + ErrorKind::PermissionDenied, + "remote control requires ChatGPT authentication", + )); + } + auth_manager.reload(); + reloaded = true; + continue; + }; + if !auth.is_chatgpt_auth() { + break auth; + } + if auth.get_account_id().is_none() && !reloaded { + auth_manager.reload(); + reloaded = true; + continue; + } + break auth; + }; + + if !auth.is_chatgpt_auth() { + return Err(io::Error::new( + ErrorKind::PermissionDenied, + "remote control requires ChatGPT authentication; API key auth is not supported", + )); + } + + Ok(RemoteControlConnectionAuth { + bearer_token: auth.get_token().map_err(io::Error::other)?, + account_id: auth.get_account_id().ok_or_else(|| { + io::Error::new( + ErrorKind::WouldBlock, + "remote control enrollment is waiting for a ChatGPT account id", + ) + })?, + }) +} + +pub(super) async fn connect_remote_control_websocket( + remote_control_target: &RemoteControlTarget, + state_db: Option<&StateRuntime>, + auth_manager: &Arc, + auth_recovery: &mut UnauthorizedRecovery, + enrollment: &mut Option, + subscribe_cursor: Option<&str>, + app_server_client_name: Option<&str>, +) -> io::Result<( + WebSocketStream>, + tungstenite::http::Response<()>, +)> { + ensure_rustls_crypto_provider(); + + let auth = load_remote_control_auth(auth_manager).await?; + let enrollment_account_id = enrollment.as_ref().map(|enrollment| &enrollment.account_id); + if enrollment_account_id.is_some_and(|account_id| account_id != &auth.account_id) { + info!( + "clearing in-memory remote control enrollment because account id changed: websocket_url={}, previous_account_id={:?}, current_account_id={:?}", + remote_control_target.websocket_url, + enrollment + .as_ref() + .map(|enrollment| enrollment.account_id.as_str()), + auth.account_id + ); + *enrollment = None; + } + + if enrollment.is_none() { + *enrollment = load_persisted_remote_control_enrollment( + state_db, + remote_control_target, + &auth.account_id, + app_server_client_name, + ) + .await; + } + + if enrollment.is_none() { + info!( + "creating new remote control enrollment: websocket_url={}, enroll_url={}, account_id={}", + remote_control_target.websocket_url, remote_control_target.enroll_url, auth.account_id + ); + let new_enrollment = match enroll_remote_control_server(remote_control_target, &auth).await + { + Ok(new_enrollment) => new_enrollment, + Err(err) + if err.kind() == ErrorKind::PermissionDenied + && recover_remote_control_auth(auth_recovery).await => + { + return Err(io::Error::other(format!( + "{err}; retrying after auth recovery" + ))); + } + Err(err) => return Err(err), + }; + if let Err(err) = update_persisted_remote_control_enrollment( + state_db, + remote_control_target, + &auth.account_id, + app_server_client_name, + Some(&new_enrollment), + ) + .await + { + warn!("failed to persist remote control enrollment in sqlite state db: {err}"); + } + info!( + "created new remote control enrollment: websocket_url={}, account_id={}, server_id={}, environment_id={}", + remote_control_target.websocket_url, + new_enrollment.account_id, + new_enrollment.server_id, + new_enrollment.environment_id + ); + *enrollment = Some(new_enrollment); + } + + let enrollment_ref = enrollment.as_ref().ok_or_else(|| { + io::Error::other("missing remote control enrollment after enrollment step") + })?; + let request = build_remote_control_websocket_request( + &remote_control_target.websocket_url, + enrollment_ref, + &auth, + subscribe_cursor, + )?; + + match connect_async(request).await { + Ok((websocket_stream, response)) => Ok((websocket_stream, response.map(|_| ()))), + Err(err) => { + match &err { + tungstenite::Error::Http(response) if response.status().as_u16() == 404 => { + info!( + "remote control websocket returned HTTP 404; clearing stale enrollment before re-enrolling: websocket_url={}, account_id={}, server_id={}, environment_id={}", + remote_control_target.websocket_url, + auth.account_id, + enrollment_ref.server_id, + enrollment_ref.environment_id + ); + if let Err(clear_err) = update_persisted_remote_control_enrollment( + state_db, + remote_control_target, + &auth.account_id, + app_server_client_name, + /*enrollment*/ None, + ) + .await + { + warn!( + "failed to clear stale remote control enrollment in sqlite state db: {clear_err}" + ); + } + *enrollment = None; + } + tungstenite::Error::Http(response) + if matches!(response.status().as_u16(), 401 | 403) => + { + if recover_remote_control_auth(auth_recovery).await { + return Err(io::Error::other(format!( + "remote control websocket auth failed with HTTP {}; retrying after auth recovery", + response.status() + ))); + } + } + _ => {} + } + Err(io::Error::other( + format_remote_control_websocket_connect_error( + &remote_control_target.websocket_url, + &err, + ), + )) + } + } +} + +async fn recover_remote_control_auth(auth_recovery: &mut UnauthorizedRecovery) -> bool { + if !auth_recovery.has_next() { + return false; + } + + let mode = auth_recovery.mode_name(); + let step = auth_recovery.step_name(); + match auth_recovery.next().await { + Ok(step_result) => { + info!( + "remote control websocket auth recovery succeeded: mode={mode}, step={step}, auth_state_changed={:?}", + step_result.auth_state_changed() + ); + true + } + Err(err) => { + warn!("remote control websocket auth recovery failed: mode={mode}, step={step}: {err}"); + false + } + } +} + +fn format_remote_control_websocket_connect_error( + websocket_url: &str, + err: &tungstenite::Error, +) -> String { + let mut message = + format!("failed to connect app-server remote control websocket `{websocket_url}`: {err}"); + let tungstenite::Error::Http(response) = err else { + return message; + }; + + message.push_str(&format!(", {}", format_headers(response.headers()))); + if let Some(body) = response.body().as_ref() + && !body.is_empty() + { + let body_preview = preview_remote_control_response_body(body); + message.push_str(&format!(", body: {body_preview}")); + } + + message +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::outgoing_message::OutgoingMessage; + use crate::transport::remote_control::ServerEvent; + use crate::transport::remote_control::protocol::StreamId; + use crate::transport::remote_control::protocol::normalize_remote_control_url; + use chrono::Utc; + use codex_app_server_protocol::AuthMode; + use codex_app_server_protocol::ConfigWarningNotification; + use codex_app_server_protocol::ServerNotification; + use codex_config::types::AuthCredentialsStoreMode; + use codex_core::test_support::auth_manager_from_auth; + use codex_login::AuthDotJson; + use codex_login::CodexAuth; + use codex_login::save_auth; + use codex_login::token_data::TokenData; + use codex_login::token_data::parse_chatgpt_jwt_claims; + use codex_state::StateRuntime; + use futures::StreamExt; + use pretty_assertions::assert_eq; + use std::sync::Arc; + use tempfile::TempDir; + use tokio::io::AsyncBufReadExt; + use tokio::io::AsyncWriteExt; + use tokio::io::BufReader; + use tokio::net::TcpListener; + use tokio::net::TcpStream; + use tokio::sync::mpsc; + use tokio::time::Duration; + use tokio::time::timeout; + use tokio_tungstenite::accept_async; + + async fn remote_control_state_runtime(codex_home: &TempDir) -> Arc { + StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string()) + .await + .expect("state runtime should initialize") + } + + fn remote_control_auth_manager() -> Arc { + auth_manager_from_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + } + + fn remote_control_url_for_listener(listener: &TcpListener) -> String { + format!( + "http://localhost:{}/backend-api/", + listener + .local_addr() + .expect("listener should have a local addr") + .port() + ) + } + + fn remote_control_auth_dot_json(access_token: &str) -> AuthDotJson { + #[derive(serde::Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = serde_json::json!({ + "email": "user@example.com", + "https://api.openai.com/auth": { + "chatgpt_user_id": "user-12345", + "user_id": "user-12345", + "chatgpt_account_id": "account_id" + } + }); + let b64 = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + let header_b64 = b64(&serde_json::to_vec(&header).expect("header should serialize")); + let payload_b64 = b64(&serde_json::to_vec(&payload).expect("payload should serialize")); + let fake_jwt = format!("{header_b64}.{payload_b64}.sig"); + + AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token: parse_chatgpt_jwt_claims(&fake_jwt).expect("fake jwt should parse"), + access_token: access_token.to_string(), + refresh_token: "refresh-token".to_string(), + account_id: Some("account_id".to_string()), + }), + last_refresh: Some(Utc::now()), + } + } + + #[tokio::test] + async fn connect_remote_control_websocket_includes_http_error_details() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let expected_error = format!( + "failed to connect app-server remote control websocket `{}`: HTTP error: 503 Service Unavailable, request-id: , cf-ray: , body: upstream unavailable", + remote_control_target.websocket_url + ); + let server_task = tokio::spawn(async move { + let (stream, request_line) = accept_http_request(&listener).await; + assert_eq!( + request_line, + "GET /backend-api/wham/remote/control/server HTTP/1.1" + ); + respond_with_status_and_headers( + stream, + "503 Service Unavailable", + &[("x-trace-id", "trace-503"), ("x-region", "us-east-1")], + "upstream unavailable", + ) + .await; + }); + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let auth_manager = remote_control_auth_manager(); + let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut enrollment = Some(RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_test".to_string(), + server_id: "srv_e_test".to_string(), + server_name: "test-server".to_string(), + }); + + let err = match connect_remote_control_websocket( + &remote_control_target, + Some(state_db.as_ref()), + &auth_manager, + &mut auth_recovery, + &mut enrollment, + /*subscribe_cursor*/ None, + /*app_server_client_name*/ None, + ) + .await + { + Ok(_) => panic!("http error response should fail the websocket connect"), + Err(err) => err, + }; + + server_task.await.expect("server task should succeed"); + assert_eq!(err.to_string(), expected_error); + } + + #[tokio::test] + async fn connect_remote_control_websocket_recovers_after_unauthorized_reload() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let server_task = tokio::spawn(async move { + let (stream, request_line) = accept_http_request(&listener).await; + assert_eq!( + request_line, + "GET /backend-api/wham/remote/control/server HTTP/1.1" + ); + respond_with_status_and_headers(stream, "401 Unauthorized", &[], "unauthorized").await; + }); + let codex_home = TempDir::new().expect("temp dir should create"); + save_auth( + codex_home.path(), + &remote_control_auth_dot_json("stale-token"), + AuthCredentialsStoreMode::File, + ) + .expect("stale auth should save"); + let state_db = remote_control_state_runtime(&codex_home).await; + let auth_manager = AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + ); + let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut enrollment = Some(RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_test".to_string(), + server_id: "srv_e_test".to_string(), + server_name: "test-server".to_string(), + }); + save_auth( + codex_home.path(), + &remote_control_auth_dot_json("fresh-token"), + AuthCredentialsStoreMode::File, + ) + .expect("fresh auth should save"); + + let err = connect_remote_control_websocket( + &remote_control_target, + Some(state_db.as_ref()), + &auth_manager, + &mut auth_recovery, + &mut enrollment, + /*subscribe_cursor*/ None, + /*app_server_client_name*/ None, + ) + .await + .expect_err("unauthorized response should fail the websocket connect"); + + server_task.await.expect("server task should succeed"); + assert_eq!( + err.to_string(), + "remote control websocket auth failed with HTTP 401 Unauthorized; retrying after auth recovery" + ); + assert_eq!( + auth_manager + .auth() + .await + .expect("auth should remain available") + .get_token() + .expect("token should be readable"), + "fresh-token" + ); + } + + #[tokio::test] + async fn connect_remote_control_websocket_recovers_after_unauthorized_enrollment() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let enroll_url = remote_control_target.enroll_url.clone(); + let server_task = tokio::spawn(async move { + let (stream, request_line) = accept_http_request(&listener).await; + assert_eq!( + request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + respond_with_status_and_headers(stream, "401 Unauthorized", &[], "unauthorized").await; + }); + let codex_home = TempDir::new().expect("temp dir should create"); + save_auth( + codex_home.path(), + &remote_control_auth_dot_json("stale-token"), + AuthCredentialsStoreMode::File, + ) + .expect("stale auth should save"); + let state_db = remote_control_state_runtime(&codex_home).await; + let auth_manager = AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + ); + let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut enrollment = None; + save_auth( + codex_home.path(), + &remote_control_auth_dot_json("fresh-token"), + AuthCredentialsStoreMode::File, + ) + .expect("fresh auth should save"); + + let err = connect_remote_control_websocket( + &remote_control_target, + Some(state_db.as_ref()), + &auth_manager, + &mut auth_recovery, + &mut enrollment, + /*subscribe_cursor*/ None, + /*app_server_client_name*/ None, + ) + .await + .expect_err("unauthorized enrollment should fail the websocket connect"); + + server_task.await.expect("server task should succeed"); + assert_eq!( + err.to_string(), + format!( + "remote control server enrollment failed at `{enroll_url}`: HTTP 401 Unauthorized, request-id: , cf-ray: , body: unauthorized; retrying after auth recovery" + ) + ); + assert_eq!( + auth_manager + .auth() + .await + .expect("auth should remain available") + .get_token() + .expect("token should be readable"), + "fresh-token" + ); + } + + #[tokio::test] + async fn run_remote_control_websocket_loop_shutdown_cancels_reconnect_backoff() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + drop(listener); + + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let (transport_event_tx, transport_event_rx) = mpsc::channel(1); + drop(transport_event_rx); + let shutdown_token = CancellationToken::new(); + let (_enabled_tx, enabled_rx) = watch::channel(true); + let websocket_task = tokio::spawn({ + let shutdown_token = shutdown_token.clone(); + async move { + RemoteControlWebsocket::new( + remote_control_url, + Some(remote_control_target), + /*state_db*/ None, + remote_control_auth_manager(), + transport_event_tx, + shutdown_token, + enabled_rx, + ) + .run(/*app_server_client_name_rx*/ None) + .await + } + }); + + tokio::time::sleep(Duration::from_millis(50)).await; + shutdown_token.cancel(); + + timeout(Duration::from_millis(100), websocket_task) + .await + .expect("shutdown should cancel reconnect backoff") + .expect("websocket task should join"); + } + + #[tokio::test] + async fn run_server_writer_inner_sends_periodic_ping_frames() { + let (client_stream, mut server_stream) = connected_websocket_pair().await; + let (websocket_writer, _websocket_reader) = client_stream.split(); + let (outbound_buffer, used_rx) = BoundedOutboundBuffer::new(); + let state = Arc::new(Mutex::new(WebsocketState { + outbound_buffer, + subscribe_cursor: None, + next_seq_id: 0, + })); + let (_server_event_tx, server_event_rx) = mpsc::channel(super::super::CHANNEL_CAPACITY); + let server_event_rx = Arc::new(Mutex::new(server_event_rx)); + let shutdown_token = CancellationToken::new(); + let writer_task = tokio::spawn(RemoteControlWebsocket::run_server_writer_inner( + state, + server_event_rx, + used_rx, + websocket_writer, + Duration::from_millis(20), + shutdown_token.clone(), + )); + + let message = timeout(Duration::from_secs(5), server_stream.next()) + .await + .expect("ping frame should arrive in time") + .expect("server websocket should stay open") + .expect("ping frame should read"); + assert!(matches!(message, tungstenite::Message::Ping(_))); + + shutdown_token.cancel(); + writer_task + .await + .expect("writer task should join") + .expect("writer should stop cleanly"); + } + + #[tokio::test] + async fn run_websocket_reader_inner_times_out_without_pong_frames() { + let (client_stream, _server_stream) = connected_websocket_pair().await; + let (_websocket_writer, websocket_reader) = client_stream.split(); + let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new(); + let state = Arc::new(Mutex::new(WebsocketState { + outbound_buffer, + subscribe_cursor: None, + next_seq_id: 0, + })); + let (server_event_tx, _server_event_rx) = mpsc::channel(super::super::CHANNEL_CAPACITY); + let (transport_event_tx, _transport_event_rx) = + mpsc::channel(super::super::CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let client_tracker = Arc::new(Mutex::new(ClientTracker::new( + server_event_tx, + transport_event_tx, + &shutdown_token, + ))); + + let err = timeout( + Duration::from_secs(5), + RemoteControlWebsocket::run_websocket_reader_inner( + client_tracker, + state, + websocket_reader, + Duration::from_millis(100), + shutdown_token, + ), + ) + .await + .expect("reader should time out waiting for pong") + .expect_err("missing pong should fail the websocket reader"); + + assert_eq!(err.kind(), ErrorKind::TimedOut); + assert_eq!(err.to_string(), "remote control websocket pong timeout"); + } + + #[test] + fn outbound_buffer_acks_by_client_id_across_stream_ids() { + let (mut outbound_buffer, used_rx) = BoundedOutboundBuffer::new(); + let client_1 = ClientId("client-1".to_string()); + let client_2 = ClientId("client-2".to_string()); + + outbound_buffer.insert(&server_envelope( + &client_1, + "stream-1", + /*seq_id*/ 0, + "first-client-old-stream", + )); + outbound_buffer.insert(&server_envelope( + &client_2, + "stream-1", + /*seq_id*/ 1, + "second-client", + )); + outbound_buffer.insert(&server_envelope( + &client_1, + "stream-2", + /*seq_id*/ 2, + "first-client-new-stream", + )); + + outbound_buffer.ack(&client_1, /*acked_seq_id*/ 2); + + let mut retained = outbound_buffer + .server_envelopes() + .map(|server_envelope| { + ( + server_envelope.client_id.0.as_str(), + server_envelope.stream_id.0.as_str(), + server_envelope.seq_id, + ) + }) + .collect::>(); + retained.sort_unstable(); + assert_eq!(retained, vec![("client-2", "stream-1", 1)]); + assert_eq!(*used_rx.borrow(), 1); + } + + #[test] + fn outbound_buffer_retains_unacked_messages_until_ack_advances() { + let (mut outbound_buffer, used_rx) = BoundedOutboundBuffer::new(); + let client_1 = ClientId("client-1".to_string()); + let client_2 = ClientId("client-2".to_string()); + + outbound_buffer.insert(&server_envelope( + &client_1, + "stream-1", + /*seq_id*/ 0, + "first-old", + )); + outbound_buffer.insert(&server_envelope( + &client_1, + "stream-2", + /*seq_id*/ 1, + "first-new", + )); + outbound_buffer.insert(&server_envelope( + &client_2, "stream-1", /*seq_id*/ 2, "second", + )); + + outbound_buffer.ack(&client_1, /*acked_seq_id*/ 0); + + let mut retained = outbound_buffer + .server_envelopes() + .map(|server_envelope| { + ( + server_envelope.client_id.0.as_str(), + server_envelope.stream_id.0.as_str(), + server_envelope.seq_id, + ) + }) + .collect::>(); + retained.sort_unstable(); + assert_eq!( + retained, + vec![("client-1", "stream-2", 1), ("client-2", "stream-1", 2)] + ); + assert_eq!(*used_rx.borrow(), 2); + } + + fn server_envelope( + client_id: &ClientId, + stream_id: &str, + seq_id: u64, + summary: &str, + ) -> ServerEnvelope { + ServerEnvelope { + event: ServerEvent::ServerMessage { + message: Box::new(OutgoingMessage::AppServerNotification( + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: summary.to_string(), + details: None, + path: None, + range: None, + }), + )), + }, + client_id: client_id.clone(), + stream_id: StreamId(stream_id.to_string()), + seq_id, + } + } + + async fn accept_http_request(listener: &TcpListener) -> (TcpStream, String) { + let (stream, _) = timeout(Duration::from_secs(5), listener.accept()) + .await + .expect("HTTP request should arrive in time") + .expect("listener accept should succeed"); + let mut reader = BufReader::new(stream); + + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .await + .expect("request line should read"); + loop { + let mut line = String::new(); + reader + .read_line(&mut line) + .await + .expect("header line should read"); + if line == "\r\n" { + break; + } + } + + ( + reader.into_inner(), + request_line.trim_end_matches("\r\n").to_string(), + ) + } + + async fn connected_websocket_pair() -> ( + WebSocketStream>, + WebSocketStream, + ) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let connect_task = tokio::spawn(connect_async(format!( + "ws://{}", + listener + .local_addr() + .expect("listener should have a local addr") + ))); + let (server_stream, _) = listener + .accept() + .await + .expect("server should accept client"); + let server_stream = accept_async(server_stream) + .await + .expect("server websocket handshake should succeed"); + let (client_stream, _) = connect_task + .await + .expect("client connect task should join") + .expect("client websocket handshake should succeed"); + + (client_stream, server_stream) + } + + async fn respond_with_status_and_headers( + mut stream: TcpStream, + status: &str, + headers: &[(&str, &str)], + body: &str, + ) { + let extra_headers = headers + .iter() + .map(|(name, value)| format!("{name}: {value}\r\n")) + .collect::(); + let response = format!( + "HTTP/1.1 {status}\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n{extra_headers}\r\n{body}", + body.len(), + ); + stream + .write_all(response.as_bytes()) + .await + .expect("response should write"); + stream.flush().await.expect("response should flush"); + } +} diff --git a/codex-rs/app-server/src/transport/stdio.rs b/codex-rs/app-server/src/transport/stdio.rs new file mode 100644 index 0000000000..20eab025fb --- /dev/null +++ b/codex-rs/app-server/src/transport/stdio.rs @@ -0,0 +1,111 @@ +use super::CHANNEL_CAPACITY; +use super::TransportEvent; +use super::forward_incoming_message; +use super::next_connection_id; +use super::serialize_outgoing_message; +use crate::outgoing_message::QueuedOutgoingMessage; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCRequest; +use std::io::ErrorKind; +use std::io::Result as IoResult; +use tokio::io; +use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncWriteExt; +use tokio::io::BufReader; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; +use tracing::debug; +use tracing::error; +use tracing::info; + +pub(crate) async fn start_stdio_connection( + transport_event_tx: mpsc::Sender, + stdio_handles: &mut Vec>, + initialize_client_name_tx: oneshot::Sender, +) -> IoResult<()> { + let connection_id = next_connection_id(); + let (writer_tx, mut writer_rx) = mpsc::channel::(CHANNEL_CAPACITY); + let writer_tx_for_reader = writer_tx.clone(); + transport_event_tx + .send(TransportEvent::ConnectionOpened { + connection_id, + writer: writer_tx, + disconnect_sender: None, + }) + .await + .map_err(|_| std::io::Error::new(ErrorKind::BrokenPipe, "processor unavailable"))?; + + let transport_event_tx_for_reader = transport_event_tx.clone(); + stdio_handles.push(tokio::spawn(async move { + let stdin = io::stdin(); + let reader = BufReader::new(stdin); + let mut lines = reader.lines(); + let mut initialize_client_name_tx = Some(initialize_client_name_tx); + + loop { + match lines.next_line().await { + Ok(Some(line)) => { + if let Some(client_name) = stdio_initialize_client_name(&line) + && let Some(initialize_client_name_tx) = initialize_client_name_tx.take() + { + let _ = initialize_client_name_tx.send(client_name); + } + if !forward_incoming_message( + &transport_event_tx_for_reader, + &writer_tx_for_reader, + connection_id, + &line, + ) + .await + { + break; + } + } + Ok(None) => break, + Err(err) => { + error!("Failed reading stdin: {err}"); + break; + } + } + } + + let _ = transport_event_tx_for_reader + .send(TransportEvent::ConnectionClosed { connection_id }) + .await; + debug!("stdin reader finished (EOF)"); + })); + + stdio_handles.push(tokio::spawn(async move { + let mut stdout = io::stdout(); + while let Some(queued_message) = writer_rx.recv().await { + let Some(mut json) = serialize_outgoing_message(queued_message.message) else { + continue; + }; + json.push('\n'); + if let Err(err) = stdout.write_all(json.as_bytes()).await { + error!("Failed to write to stdout: {err}"); + break; + } + if let Some(write_complete_tx) = queued_message.write_complete_tx { + let _ = write_complete_tx.send(()); + } + } + info!("stdout writer exited (channel closed)"); + })); + + Ok(()) +} + +fn stdio_initialize_client_name(line: &str) -> Option { + let message = serde_json::from_str::(line).ok()?; + let JSONRPCMessage::Request(JSONRPCRequest { method, params, .. }) = message else { + return None; + }; + if method != "initialize" { + return None; + } + let params = serde_json::from_value::(params?).ok()?; + Some(params.client_info.name) +} diff --git a/codex-rs/app-server/src/transport/websocket.rs b/codex-rs/app-server/src/transport/websocket.rs new file mode 100644 index 0000000000..41b138e216 --- /dev/null +++ b/codex-rs/app-server/src/transport/websocket.rs @@ -0,0 +1,305 @@ +use super::CHANNEL_CAPACITY; +use super::TransportEvent; +use super::auth::WebsocketAuthPolicy; +use super::auth::authorize_upgrade; +use super::auth::should_warn_about_unauthenticated_non_loopback_listener; +use super::forward_incoming_message; +use super::next_connection_id; +use super::serialize_outgoing_message; +use crate::outgoing_message::ConnectionId; +use crate::outgoing_message::QueuedOutgoingMessage; +use axum::Router; +use axum::body::Body; +use axum::extract::ConnectInfo; +use axum::extract::State; +use axum::extract::ws::Message as WebSocketMessage; +use axum::extract::ws::WebSocket; +use axum::extract::ws::WebSocketUpgrade; +use axum::http::HeaderMap; +use axum::http::Request; +use axum::http::StatusCode; +use axum::http::header::ORIGIN; +use axum::middleware; +use axum::middleware::Next; +use axum::response::IntoResponse; +use axum::response::Response; +use axum::routing::any; +use axum::routing::get; +use futures::SinkExt; +use futures::StreamExt; +use owo_colors::OwoColorize; +use owo_colors::Stream; +use owo_colors::Style; +use std::io::Result as IoResult; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::net::TcpListener; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use tracing::error; +use tracing::info; +use tracing::warn; + +fn colorize(text: &str, style: Style) -> String { + text.if_supports_color(Stream::Stderr, |value| value.style(style)) + .to_string() +} + +#[allow(clippy::print_stderr)] +fn print_websocket_startup_banner(addr: SocketAddr) { + let title = colorize("codex app-server (WebSockets)", Style::new().bold().cyan()); + let listening_label = colorize("listening on:", Style::new().dimmed()); + let listen_url = colorize(&format!("ws://{addr}"), Style::new().green()); + let ready_label = colorize("readyz:", Style::new().dimmed()); + let ready_url = colorize(&format!("http://{addr}/readyz"), Style::new().green()); + let health_label = colorize("healthz:", Style::new().dimmed()); + let health_url = colorize(&format!("http://{addr}/healthz"), Style::new().green()); + let note_label = colorize("note:", Style::new().dimmed()); + eprintln!("{title}"); + eprintln!(" {listening_label} {listen_url}"); + eprintln!(" {ready_label} {ready_url}"); + eprintln!(" {health_label} {health_url}"); + if addr.ip().is_loopback() { + eprintln!( + " {note_label} binds localhost only (use SSH port-forwarding for remote access)" + ); + } else { + eprintln!( + " {note_label} websocket auth is opt-in in this build; configure `--ws-auth ...` before real remote use" + ); + } +} + +#[derive(Clone)] +struct WebSocketListenerState { + transport_event_tx: mpsc::Sender, + auth_policy: Arc, +} + +async fn health_check_handler() -> StatusCode { + StatusCode::OK +} + +async fn reject_requests_with_origin_header( + request: Request, + next: Next, +) -> Result { + if request.headers().contains_key(ORIGIN) { + warn!( + method = %request.method(), + uri = %request.uri(), + "rejecting websocket listener request with Origin header" + ); + Err(StatusCode::FORBIDDEN) + } else { + Ok(next.run(request).await) + } +} + +async fn websocket_upgrade_handler( + websocket: WebSocketUpgrade, + ConnectInfo(peer_addr): ConnectInfo, + State(state): State, + headers: HeaderMap, +) -> impl IntoResponse { + if let Err(err) = authorize_upgrade(&headers, state.auth_policy.as_ref()) { + warn!( + %peer_addr, + message = err.message(), + "rejecting websocket client during upgrade" + ); + return (err.status_code(), err.message()).into_response(); + } + let connection_id = next_connection_id(); + info!(%peer_addr, "websocket client connected"); + websocket + .on_upgrade(move |stream| async move { + run_websocket_connection(connection_id, stream, state.transport_event_tx).await; + }) + .into_response() +} + +pub(crate) async fn start_websocket_acceptor( + bind_address: SocketAddr, + transport_event_tx: mpsc::Sender, + shutdown_token: CancellationToken, + auth_policy: WebsocketAuthPolicy, +) -> IoResult> { + if should_warn_about_unauthenticated_non_loopback_listener(bind_address, &auth_policy) { + warn!( + %bind_address, + "starting non-loopback websocket listener without auth; websocket auth is opt-in for now and will become the default in a future release" + ); + } + let listener = TcpListener::bind(bind_address).await?; + let local_addr = listener.local_addr()?; + print_websocket_startup_banner(local_addr); + info!("app-server websocket listening on ws://{local_addr}"); + + let router = Router::new() + .route("/readyz", get(health_check_handler)) + .route("/healthz", get(health_check_handler)) + .fallback(any(websocket_upgrade_handler)) + .layer(middleware::from_fn(reject_requests_with_origin_header)) + .with_state(WebSocketListenerState { + transport_event_tx, + auth_policy: Arc::new(auth_policy), + }); + let server = axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { + shutdown_token.cancelled().await; + }); + Ok(tokio::spawn(async move { + if let Err(err) = server.await { + error!("websocket acceptor failed: {err}"); + } + info!("websocket acceptor shutting down"); + })) +} + +async fn run_websocket_connection( + connection_id: ConnectionId, + websocket_stream: WebSocket, + transport_event_tx: mpsc::Sender, +) { + let (writer_tx, writer_rx) = mpsc::channel::(CHANNEL_CAPACITY); + let writer_tx_for_reader = writer_tx.clone(); + let disconnect_token = CancellationToken::new(); + if transport_event_tx + .send(TransportEvent::ConnectionOpened { + connection_id, + writer: writer_tx, + disconnect_sender: Some(disconnect_token.clone()), + }) + .await + .is_err() + { + return; + } + + let (websocket_writer, websocket_reader) = websocket_stream.split(); + let (writer_control_tx, writer_control_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let mut outbound_task = tokio::spawn(run_websocket_outbound_loop( + websocket_writer, + writer_rx, + writer_control_rx, + disconnect_token.clone(), + )); + let mut inbound_task = tokio::spawn(run_websocket_inbound_loop( + websocket_reader, + transport_event_tx.clone(), + writer_tx_for_reader, + writer_control_tx, + connection_id, + disconnect_token.clone(), + )); + + tokio::select! { + _ = &mut outbound_task => { + disconnect_token.cancel(); + inbound_task.abort(); + } + _ = &mut inbound_task => { + disconnect_token.cancel(); + outbound_task.abort(); + } + } + + let _ = transport_event_tx + .send(TransportEvent::ConnectionClosed { connection_id }) + .await; +} + +async fn run_websocket_outbound_loop( + mut websocket_writer: futures::stream::SplitSink, + mut writer_rx: mpsc::Receiver, + mut writer_control_rx: mpsc::Receiver, + disconnect_token: CancellationToken, +) { + loop { + tokio::select! { + _ = disconnect_token.cancelled() => { + break; + } + message = writer_control_rx.recv() => { + let Some(message) = message else { + break; + }; + if websocket_writer.send(message).await.is_err() { + break; + } + } + queued_message = writer_rx.recv() => { + let Some(queued_message) = queued_message else { + break; + }; + let Some(json) = serialize_outgoing_message(queued_message.message) else { + continue; + }; + if websocket_writer.send(WebSocketMessage::Text(json.into())).await.is_err() { + break; + } + if let Some(write_complete_tx) = queued_message.write_complete_tx { + let _ = write_complete_tx.send(()); + } + } + } + } +} + +async fn run_websocket_inbound_loop( + mut websocket_reader: futures::stream::SplitStream, + transport_event_tx: mpsc::Sender, + writer_tx_for_reader: mpsc::Sender, + writer_control_tx: mpsc::Sender, + connection_id: ConnectionId, + disconnect_token: CancellationToken, +) { + loop { + tokio::select! { + _ = disconnect_token.cancelled() => { + break; + } + incoming_message = websocket_reader.next() => { + match incoming_message { + Some(Ok(WebSocketMessage::Text(text))) => { + if !forward_incoming_message( + &transport_event_tx, + &writer_tx_for_reader, + connection_id, + text.as_ref(), + ) + .await + { + break; + } + } + Some(Ok(WebSocketMessage::Ping(payload))) => { + match writer_control_tx.try_send(WebSocketMessage::Pong(payload)) { + Ok(()) => {} + Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => break, + Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { + warn!("websocket control queue full while replying to ping; closing connection"); + break; + } + } + } + Some(Ok(WebSocketMessage::Pong(_))) => {} + Some(Ok(WebSocketMessage::Close(_))) | None => break, + Some(Ok(WebSocketMessage::Binary(_))) => { + warn!("dropping unsupported binary websocket message"); + } + Some(Err(err)) => { + warn!("websocket receive error: {err}"); + break; + } + } + } + } + } +} diff --git a/codex-rs/app-server/tests/common/Cargo.toml b/codex-rs/app-server/tests/common/Cargo.toml index 851ba9556d..aef2f58dff 100644 --- a/codex-rs/app-server/tests/common/Cargo.toml +++ b/codex-rs/app-server/tests/common/Cargo.toml @@ -7,13 +7,19 @@ license.workspace = true [lib] path = "lib.rs" +[lints] +workspace = true + [dependencies] anyhow = { workspace = true } base64 = { workspace = true } chrono = { workspace = true } codex-app-server-protocol = { workspace = true } +codex-config = { workspace = true } codex-core = { workspace = true } codex-features = { workspace = true } +codex-login = { workspace = true } +codex-models-manager = { workspace = true } codex-protocol = { workspace = true } codex-utils-cargo-bin = { workspace = true } serde = { workspace = true } diff --git a/codex-rs/app-server/tests/common/auth_fixtures.rs b/codex-rs/app-server/tests/common/auth_fixtures.rs index c52109ac5b..99334f0770 100644 --- a/codex-rs/app-server/tests/common/auth_fixtures.rs +++ b/codex-rs/app-server/tests/common/auth_fixtures.rs @@ -7,11 +7,11 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD; use chrono::DateTime; use chrono::Utc; use codex_app_server_protocol::AuthMode; -use codex_core::auth::AuthCredentialsStoreMode; -use codex_core::auth::AuthDotJson; -use codex_core::auth::save_auth; -use codex_core::token_data::TokenData; -use codex_core::token_data::parse_chatgpt_jwt_claims; +use codex_config::types::AuthCredentialsStoreMode; +use codex_login::AuthDotJson; +use codex_login::save_auth; +use codex_login::token_data::TokenData; +use codex_login::token_data::parse_chatgpt_jwt_claims; use serde_json::json; /// Builder for writing a fake ChatGPT auth.json in tests. diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index fe21cdafff..e660b82646 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -45,7 +45,9 @@ use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCRequest; use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::LoginAccountParams; +use codex_app_server_protocol::McpResourceReadParams; use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::PluginInstallParams; @@ -79,7 +81,7 @@ use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::WindowsSandboxSetupStartParams; -use codex_core::default_client::CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR; +use codex_login::default_client::CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR; use tokio::process::Command; pub struct McpProcess { @@ -481,6 +483,15 @@ impl McpProcess { self.send_request("app/list", params).await } + /// Send an `mcpServer/resource/read` JSON-RPC request. + pub async fn send_mcp_resource_read_request( + &mut self, + params: McpResourceReadParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("mcpServer/resource/read", params).await + } + /// Send a `skills/list` JSON-RPC request. pub async fn send_skills_list_request( &mut self, @@ -526,6 +537,15 @@ impl McpProcess { self.send_request("plugin/read", params).await } + /// Send an `mcpServerStatus/list` JSON-RPC request. + pub async fn send_list_mcp_server_status_request( + &mut self, + params: ListMcpServerStatusParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("mcpServerStatus/list", params).await + } + /// Send a JSON-RPC request with raw params for protocol-level validation tests. pub async fn send_raw_request( &mut self, @@ -840,6 +860,14 @@ impl McpProcess { self.send_request("account/login/start", Some(params)).await } + /// Send an `account/login/start` JSON-RPC request for ChatGPT device code login. + pub async fn send_login_account_chatgpt_device_code_request(&mut self) -> anyhow::Result { + let params = serde_json::json!({ + "type": "chatgptDeviceCode" + }); + self.send_request("account/login/start", Some(params)).await + } + /// Send an `account/login/cancel` JSON-RPC request. pub async fn send_cancel_login_account_request( &mut self, diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 427f6cc1f2..4173747616 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -1,6 +1,7 @@ use chrono::DateTime; use chrono::Utc; use codex_core::test_support::all_model_presets; +use codex_models_manager::client_version_to_whole; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::ModelInfo; @@ -27,7 +28,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { }, supported_in_api: preset.supported_in_api, priority, - upgrade: preset.upgrade.as_ref().map(|u| u.into()), + upgrade: preset.upgrade.as_ref().map(Into::into), base_instructions: "base instructions".to_string(), model_messages: None, supports_reasoning_summaries: false, @@ -84,7 +85,7 @@ pub fn write_models_cache_with_models( let cache_path = codex_home.join("models_cache.json"); // DateTime serializes to RFC3339 format by default with serde let fetched_at: DateTime = Utc::now(); - let client_version = codex_core::models_manager::client_version_to_whole(); + let client_version = client_version_to_whole(); let cache = json!({ "fetched_at": fetched_at, "etag": null, diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index b67390154e..efbed53255 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -84,6 +84,7 @@ pub fn create_fake_rollout_with_source( agent_role: None, model_provider: model_provider.map(str::to_string), base_instructions: None, + developer_instructions: None, dynamic_tools: None, memory_mode: None, }; @@ -167,6 +168,7 @@ pub fn create_fake_rollout_with_text_elements( agent_role: None, model_provider: model_provider.map(str::to_string), base_instructions: None, + developer_instructions: None, dynamic_tools: None, memory_mode: None, }; diff --git a/codex-rs/app-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs index 750dccd761..5d3a3dcd4a 100644 --- a/codex-rs/app-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -12,8 +12,8 @@ use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::RequestId; -use codex_core::auth::AuthCredentialsStoreMode; -use codex_core::auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use codex_config::types::AuthCredentialsStoreMode; +use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use pretty_assertions::assert_eq; use std::path::Path; use tempfile::TempDir; @@ -160,7 +160,7 @@ async fn get_auth_status_with_api_key() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_auth_status_with_api_key_when_auth_not_required() -> Result<()> { let codex_home = TempDir::new()?; - create_config_toml_custom_provider(codex_home.path(), false)?; + create_config_toml_custom_provider(codex_home.path(), /*requires_openai_auth*/ false)?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 31108a8455..9e292d602f 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -45,7 +45,7 @@ async fn get_conversation_summary_by_thread_id_reads_rollout() -> Result<()> { META_RFC3339, PREVIEW, Some(MODEL_PROVIDER), - None, + /*git_info*/ None, )?; let thread_id = ThreadId::from_string(&conversation_id)?; let expected = expected_summary( @@ -86,7 +86,7 @@ async fn get_conversation_summary_by_relative_rollout_path_resolves_from_codex_h META_RFC3339, PREVIEW, Some(MODEL_PROVIDER), - None, + /*git_info*/ None, )?; let thread_id = ThreadId::from_string(&conversation_id)?; let rollout_path = rollout_path(codex_home.path(), FILENAME_TS, &conversation_id); diff --git a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs index 692304c6f6..1520d99e3e 100644 --- a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs +++ b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs @@ -237,7 +237,11 @@ async fn test_fuzzy_file_search_sorts_and_includes_indices() -> Result<()> { let root_path = root.path().to_string_lossy().to_string(); // Send fuzzyFileSearch request. let request_id = mcp - .send_fuzzy_file_search_request("abe", vec![root_path.clone()], None) + .send_fuzzy_file_search_request( + "abe", + vec![root_path.clone()], + /*cancellation_token*/ None, + ) .await?; // Read response and verify shape and ordering. @@ -298,7 +302,11 @@ async fn test_fuzzy_file_search_accepts_cancellation_token() -> Result<()> { let root_path = root.path().to_string_lossy().to_string(); let request_id = mcp - .send_fuzzy_file_search_request("alp", vec![root_path.clone()], None) + .send_fuzzy_file_search_request( + "alp", + vec![root_path.clone()], + /*cancellation_token*/ None, + ) .await?; let request_id_2 = mcp @@ -358,6 +366,30 @@ async fn test_fuzzy_file_search_session_streams_updates() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_fuzzy_file_search_session_update_is_case_insensitive() -> Result<()> { + let codex_home = TempDir::new()?; + let root = TempDir::new()?; + std::fs::write(root.path().join("alpha.txt"), "contents")?; + let mut mcp = initialized_mcp(&codex_home).await?; + + let root_path = root.path().to_string_lossy().to_string(); + let session_id = "session-case-insensitive"; + + mcp.start_fuzzy_file_search_session(session_id, vec![root_path.clone()]) + .await?; + mcp.update_fuzzy_file_search_session(session_id, "ALP") + .await?; + + let payload = + wait_for_session_updated(&mut mcp, session_id, "ALP", FileExpectation::NonEmpty).await?; + assert_eq!(payload.files.len(), 1); + assert_eq!(payload.files[0].root, root_path); + assert_eq!(payload.files[0].path, "alpha.txt"); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fuzzy_file_search_session_no_updates_after_complete_until_query_edited() -> Result<()> { diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 514a6542a0..3c88bcb7a4 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -28,7 +28,7 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnStatus; -use codex_core::auth::AuthCredentialsStoreMode; +use codex_config::types::AuthCredentialsStoreMode; use codex_login::login_with_api_key; use codex_protocol::account::PlanType as AccountPlanType; use core_test_support::responses; @@ -39,10 +39,14 @@ use std::path::Path; use std::time::Duration; use tempfile::TempDir; use tokio::time::timeout; +use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const LOGIN_ISSUER_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER"; // Helper to create a minimal config.toml for the app server #[derive(Default)] @@ -98,6 +102,58 @@ stream_max_retries = 0 std::fs::write(config_toml, contents) } +async fn mock_device_code_usercode(server: &MockServer, interval_seconds: u64) { + Mock::given(method("POST")) + .and(path("/api/accounts/deviceauth/usercode")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "device_auth_id": "device-auth-123", + "user_code": "CODE-12345", + "interval": interval_seconds.to_string(), + }))) + .mount(server) + .await; +} + +async fn mock_device_code_usercode_failure(server: &MockServer, status: u16) { + Mock::given(method("POST")) + .and(path("/api/accounts/deviceauth/usercode")) + .respond_with(ResponseTemplate::new(status)) + .mount(server) + .await; +} + +async fn mock_device_code_token_success(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/api/accounts/deviceauth/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "authorization_code": "poll-code-321", + "code_challenge": "code-challenge-321", + "code_verifier": "code-verifier-321", + }))) + .mount(server) + .await; +} + +async fn mock_device_code_token_failure(server: &MockServer, status: u16) { + Mock::given(method("POST")) + .and(path("/api/accounts/deviceauth/token")) + .respond_with(ResponseTemplate::new(status)) + .mount(server) + .await; +} + +async fn mock_device_code_oauth_token(server: &MockServer, id_token: &str) { + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id_token": id_token, + "access_token": "access-token-123", + "refresh_token": "refresh-token-123", + }))) + .mount(server) + .await; +} + #[tokio::test] async fn logout_account_removes_auth_and_notifies() -> Result<()> { let codex_home = TempDir::new()?; @@ -912,6 +968,305 @@ async fn login_account_chatgpt_rejected_when_forced_api() -> Result<()> { Ok(()) } +#[tokio::test] +async fn login_account_chatgpt_device_code_returns_error_when_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let mock_server = MockServer::start().await; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + base_url: Some(format!("{}/v1", mock_server.uri())), + ..Default::default() + }, + )?; + write_models_cache(codex_home.path())?; + mock_device_code_usercode_failure(&mock_server, /*status*/ 404).await; + + let issuer = mock_server.uri(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + (LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_login_account_chatgpt_device_code_request().await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert!( + err.error + .message + .contains("device code login is not enabled"), + "unexpected error: {:?}", + err.error.message + ); + + let maybe_completed = timeout( + Duration::from_millis(500), + mcp.read_stream_until_notification_message("account/login/completed"), + ) + .await; + assert!( + maybe_completed.is_err(), + "account/login/completed should not be emitted when device code start fails" + ); + assert!( + !codex_home.path().join("auth.json").exists(), + "auth.json should not be created when device code start fails" + ); + Ok(()) +} + +#[tokio::test] +async fn login_account_chatgpt_device_code_succeeds_and_notifies() -> Result<()> { + let codex_home = TempDir::new()?; + let mock_server = MockServer::start().await; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + base_url: Some(format!("{}/v1", mock_server.uri())), + ..Default::default() + }, + )?; + write_models_cache(codex_home.path())?; + + mock_device_code_usercode(&mock_server, /*interval_seconds*/ 0).await; + mock_device_code_token_success(&mock_server).await; + let id_token = encode_id_token( + &ChatGptIdTokenClaims::new() + .email("device@example.com") + .plan_type("pro") + .chatgpt_account_id("org-device"), + )?; + mock_device_code_oauth_token(&mock_server, &id_token).await; + + let issuer = mock_server.uri(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + (LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_login_account_chatgpt_device_code_request().await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let login: LoginAccountResponse = to_response(resp)?; + let LoginAccountResponse::ChatgptDeviceCode { + login_id, + verification_url, + user_code, + } = login + else { + bail!("unexpected login response: {login:?}"); + }; + assert_eq!(verification_url, format!("{issuer}/codex/device")); + assert_eq!(user_code, "CODE-12345"); + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/login/completed"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountLoginCompleted(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + assert_eq!(payload.login_id, Some(login_id)); + assert_eq!(payload.success, true); + assert_eq!(payload.error, None); + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/updated"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountUpdated(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + assert_eq!(payload.auth_mode, Some(AuthMode::Chatgpt)); + assert_eq!(payload.plan_type, Some(AccountPlanType::Pro)); + assert!( + codex_home.path().join("auth.json").exists(), + "auth.json should be created when device code login succeeds" + ); + Ok(()) +} + +#[tokio::test] +async fn login_account_chatgpt_device_code_failure_notifies_without_account_update() -> Result<()> { + let codex_home = TempDir::new()?; + let mock_server = MockServer::start().await; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + base_url: Some(format!("{}/v1", mock_server.uri())), + ..Default::default() + }, + )?; + write_models_cache(codex_home.path())?; + + mock_device_code_usercode(&mock_server, /*interval_seconds*/ 0).await; + mock_device_code_token_failure(&mock_server, /*status*/ 500).await; + + let issuer = mock_server.uri(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + (LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_login_account_chatgpt_device_code_request().await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let login: LoginAccountResponse = to_response(resp)?; + let LoginAccountResponse::ChatgptDeviceCode { login_id, .. } = login else { + bail!("unexpected login response: {login:?}"); + }; + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/login/completed"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountLoginCompleted(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + assert_eq!(payload.login_id, Some(login_id)); + assert_eq!(payload.success, false); + assert!( + payload + .error + .as_deref() + .is_some_and(|error| error.contains("device auth failed with status")), + "unexpected error: {:?}", + payload.error + ); + + let maybe_updated = timeout( + Duration::from_millis(500), + mcp.read_stream_until_notification_message("account/updated"), + ) + .await; + assert!( + maybe_updated.is_err(), + "account/updated should not be emitted when device code login fails" + ); + assert!( + !codex_home.path().join("auth.json").exists(), + "auth.json should not be created when device code login fails" + ); + Ok(()) +} + +#[tokio::test] +async fn login_account_chatgpt_device_code_can_be_cancelled() -> Result<()> { + let codex_home = TempDir::new()?; + let mock_server = MockServer::start().await; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + base_url: Some(format!("{}/v1", mock_server.uri())), + ..Default::default() + }, + )?; + write_models_cache(codex_home.path())?; + + mock_device_code_usercode(&mock_server, /*interval_seconds*/ 1).await; + mock_device_code_token_failure(&mock_server, /*status*/ 404).await; + + let issuer = mock_server.uri(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + (LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_login_account_chatgpt_device_code_request().await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let login: LoginAccountResponse = to_response(resp)?; + let LoginAccountResponse::ChatgptDeviceCode { login_id, .. } = login else { + bail!("unexpected login response: {login:?}"); + }; + + let cancel_id = mcp + .send_cancel_login_account_request(CancelLoginAccountParams { + login_id: login_id.clone(), + }) + .await?; + let cancel_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(cancel_id)), + ) + .await??; + let cancel: CancelLoginAccountResponse = to_response(cancel_resp)?; + assert_eq!(cancel.status, CancelLoginAccountStatus::Canceled); + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/login/completed"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountLoginCompleted(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + assert_eq!(payload.login_id, Some(login_id)); + assert_eq!(payload.success, false); + assert!( + payload.error.is_some(), + "expected a non-empty error on device code cancel" + ); + + let maybe_updated = timeout( + Duration::from_millis(500), + mcp.read_stream_until_notification_message("account/updated"), + ) + .await; + assert!( + maybe_updated.is_err(), + "account/updated should not be emitted when device code login is cancelled" + ); + assert!( + !codex_home.path().join("auth.json").exists(), + "auth.json should not be created when device code login is cancelled" + ); + Ok(()) +} + #[tokio::test] // Serialize tests that launch the login server since it binds to a fixed port. #[serial(login_port)] diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index e64287cb22..a4d7a7f349 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -1,10 +1,23 @@ use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::DEFAULT_CLIENT_NAME; +use app_test_support::write_chatgpt_auth; +use codex_config::types::AuthCredentialsStoreMode; +use codex_config::types::OtelExporterKind; +use codex_config::types::OtelHttpProtocol; use codex_core::config::ConfigBuilder; -use codex_core::config::types::OtelExporterKind; -use codex_core::config::types::OtelHttpProtocol; use pretty_assertions::assert_eq; +use serde_json::Value; use std::collections::HashMap; +use std::path::Path; +use std::time::Duration; use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; const SERVICE_VERSION: &str = "0.0.0-test"; @@ -31,7 +44,7 @@ async fn app_server_default_analytics_disabled_without_flag() -> Result<()> { &config, SERVICE_VERSION, Some("codex-app-server"), - false, + /*default_analytics_enabled*/ false, ) .map_err(|err| anyhow::anyhow!(err.to_string()))?; @@ -56,7 +69,7 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { &config, SERVICE_VERSION, Some("codex-app-server"), - true, + /*default_analytics_enabled*/ true, ) .map_err(|err| anyhow::anyhow!(err.to_string()))?; @@ -65,3 +78,91 @@ async fn app_server_default_analytics_enabled_with_flag() -> Result<()> { assert_eq!(has_metrics, true); Ok(()) } + +pub(crate) async fn enable_analytics_capture(server: &MockServer, codex_home: &Path) -> Result<()> { + Mock::given(method("POST")) + .and(path("/codex/analytics-events/events")) + .respond_with(ResponseTemplate::new(200)) + .mount(server) + .await; + + write_chatgpt_auth( + codex_home, + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + Ok(()) +} + +pub(crate) async fn wait_for_analytics_payload( + server: &MockServer, + read_timeout: Duration, +) -> Result { + let body = timeout(read_timeout, async { + loop { + let Some(requests) = server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + if let Some(request) = requests.iter().find(|request| { + request.method == "POST" && request.url.path() == "/codex/analytics-events/events" + }) { + break request.body.clone(); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await?; + serde_json::from_slice(&body).map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}")) +} + +pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { + let events = payload["events"] + .as_array() + .ok_or_else(|| anyhow::anyhow!("analytics payload missing events array"))?; + events + .iter() + .find(|event| event["event_type"] == "codex_thread_initialized") + .ok_or_else(|| anyhow::anyhow!("codex_thread_initialized event should be present")) +} + +pub(crate) fn assert_basic_thread_initialized_event( + event: &Value, + thread_id: &str, + expected_model: &str, + initialization_mode: &str, +) { + assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!( + event["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_CLIENT_NAME + ); + assert_eq!( + event["event_params"]["app_server_client"]["client_name"], + DEFAULT_CLIENT_NAME + ); + assert_eq!( + event["event_params"]["app_server_client"]["rpc_transport"], + "stdio" + ); + assert_eq!(event["event_params"]["model"], expected_model); + assert_eq!(event["event_params"]["ephemeral"], false); + assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); + assert_eq!( + event["event_params"]["initialization_mode"], + initialization_mode + ); + assert!(event["event_params"]["created_at"].as_u64().is_some()); +} diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index a19cdad857..57a27961af 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::collections::BTreeMap; use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex as StdMutex; @@ -27,15 +28,16 @@ use codex_app_server_protocol::AppScreenshot; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; 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_core::auth::AuthCredentialsStoreMode; -use codex_core::auth::AuthDotJson; -use codex_core::auth::save_auth; +use codex_config::types::AuthCredentialsStoreMode; +use codex_login::AuthDotJson; +use codex_login::save_auth; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; use rmcp::model::JsonObject; @@ -1201,6 +1203,108 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu Ok(()) } +#[tokio::test] +async fn experimental_feature_enablement_set_refreshes_apps_list_when_apps_turn_on() -> Result<()> { + let initial_connectors = vec![AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha v1".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + let (server_url, server_handle, server_control) = start_apps_server_with_delays_and_control( + initial_connectors, + Vec::new(), + Duration::ZERO, + Duration::ZERO, + ) + .await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-enable-refresh") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let disable_request = mcp + .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { + enablement: BTreeMap::from([("apps".to_string(), false)]), + }) + .await?; + let _disable_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(disable_request)), + ) + .await??; + + server_control.set_connectors(vec![AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha v2".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }]); + server_control.set_tools(vec![connector_tool("alpha", "Alpha App")?]); + + let enable_request = mcp + .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { + enablement: BTreeMap::from([("apps".to_string(), true)]), + }) + .await?; + let _enable_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(enable_request)), + ) + .await??; + + let update = read_app_list_updated_notification(&mut mcp).await?; + assert_eq!( + update.data, + vec![AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha v2".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }] + ); + + server_handle.abort(); + Ok(()) +} + async fn read_app_list_updated_notification( mcp: &mut McpProcess, ) -> Result { diff --git a/codex-rs/app-server/tests/suite/v2/command_exec.rs b/codex-rs/app-server/tests/suite/v2/command_exec.rs index ecd897eb84..50e8285665 100644 --- a/codex-rs/app-server/tests/suite/v2/command_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/command_exec.rs @@ -722,15 +722,15 @@ async fn command_exec_process_ids_are_connection_scoped_and_disconnect_terminate let mut ws1 = connect_websocket(bind_addr).await?; let mut ws2 = connect_websocket(bind_addr).await?; - send_initialize_request(&mut ws1, 1, "ws_client_one").await?; - read_initialize_response(&mut ws1, 1).await?; - send_initialize_request(&mut ws2, 2, "ws_client_two").await?; - read_initialize_response(&mut ws2, 2).await?; + send_initialize_request(&mut ws1, /*id*/ 1, "ws_client_one").await?; + read_initialize_response(&mut ws1, /*request_id*/ 1).await?; + send_initialize_request(&mut ws2, /*id*/ 2, "ws_client_two").await?; + read_initialize_response(&mut ws2, /*request_id*/ 2).await?; send_request( &mut ws1, "command/exec", - 101, + /*id*/ 101, Some(serde_json::json!({ "command": [ "python3", @@ -749,12 +749,12 @@ async fn command_exec_process_ids_are_connection_scoped_and_disconnect_terminate assert_eq!(delta.stream, CommandExecOutputStream::Stdout); let delta_text = String::from_utf8(STANDARD.decode(&delta.delta_base64)?)?; assert!(delta_text.contains("ready")); - wait_for_process_marker(&marker, true).await?; + wait_for_process_marker(&marker, /*should_exist*/ true).await?; send_request( &mut ws2, "command/exec/terminate", - 102, + /*id*/ 102, Some(serde_json::json!({ "processId": "shared-process", })), @@ -773,12 +773,12 @@ async fn command_exec_process_ids_are_connection_scoped_and_disconnect_terminate terminate_error.error.message, "no active command/exec for process id \"shared-process\"" ); - wait_for_process_marker(&marker, true).await?; + wait_for_process_marker(&marker, /*should_exist*/ true).await?; assert_no_message(&mut ws2, Duration::from_millis(250)).await?; ws1.close(None).await?; - wait_for_process_marker(&marker, false).await?; + wait_for_process_marker(&marker, /*should_exist*/ false).await?; process .kill() diff --git a/codex-rs/app-server/tests/suite/v2/compaction.rs b/codex-rs/app-server/tests/suite/v2/compaction.rs index c0922d3256..e7661546ac 100644 --- a/codex-rs/app-server/tests/suite/v2/compaction.rs +++ b/codex-rs/app-server/tests/suite/v2/compaction.rs @@ -28,7 +28,7 @@ use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; -use codex_core::auth::AuthCredentialsStoreMode; +use codex_config::types::AuthCredentialsStoreMode; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use core_test_support::responses; @@ -50,19 +50,19 @@ async fn auto_compaction_local_emits_started_and_completed_items() -> Result<()> let server = responses::start_mock_server().await; let sse1 = responses::sse(vec![ responses::ev_assistant_message("m1", "FIRST_REPLY"), - responses::ev_completed_with_tokens("r1", 70_000), + responses::ev_completed_with_tokens("r1", /*total_tokens*/ 70_000), ]); let sse2 = responses::sse(vec![ responses::ev_assistant_message("m2", "SECOND_REPLY"), - responses::ev_completed_with_tokens("r2", 330_000), + responses::ev_completed_with_tokens("r2", /*total_tokens*/ 330_000), ]); let sse3 = responses::sse(vec![ responses::ev_assistant_message("m3", "LOCAL_SUMMARY"), - responses::ev_completed_with_tokens("r3", 200), + responses::ev_completed_with_tokens("r3", /*total_tokens*/ 200), ]); let sse4 = responses::sse(vec![ responses::ev_assistant_message("m4", "FINAL_REPLY"), - responses::ev_completed_with_tokens("r4", 120), + responses::ev_completed_with_tokens("r4", /*total_tokens*/ 120), ]); responses::mount_sse_sequence(&server, vec![sse1, sse2, sse3, sse4]).await; @@ -72,7 +72,7 @@ async fn auto_compaction_local_emits_started_and_completed_items() -> Result<()> &server.uri(), &BTreeMap::default(), AUTO_COMPACT_LIMIT, - None, + /*requires_openai_auth*/ None, "mock_provider", COMPACT_PROMPT, )?; @@ -110,15 +110,15 @@ async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<() let server = responses::start_mock_server().await; let sse1 = responses::sse(vec![ responses::ev_assistant_message("m1", "FIRST_REPLY"), - responses::ev_completed_with_tokens("r1", 70_000), + responses::ev_completed_with_tokens("r1", /*total_tokens*/ 70_000), ]); let sse2 = responses::sse(vec![ responses::ev_assistant_message("m2", "SECOND_REPLY"), - responses::ev_completed_with_tokens("r2", 330_000), + responses::ev_completed_with_tokens("r2", /*total_tokens*/ 330_000), ]); let sse3 = responses::sse(vec![ responses::ev_assistant_message("m3", "FINAL_REPLY"), - responses::ev_completed_with_tokens("r3", 120), + responses::ev_completed_with_tokens("r3", /*total_tokens*/ 120), ]); let responses_log = responses::mount_sse_sequence(&server, vec![sse1, sse2, sse3]).await; @@ -197,7 +197,7 @@ async fn thread_compact_start_triggers_compaction_and_returns_empty_response() - let server = responses::start_mock_server().await; let sse = responses::sse(vec![ responses::ev_assistant_message("m1", "MANUAL_COMPACT_SUMMARY"), - responses::ev_completed_with_tokens("r1", 200), + responses::ev_completed_with_tokens("r1", /*total_tokens*/ 200), ]); responses::mount_sse_sequence(&server, vec![sse]).await; @@ -207,7 +207,7 @@ async fn thread_compact_start_triggers_compaction_and_returns_empty_response() - &server.uri(), &BTreeMap::default(), AUTO_COMPACT_LIMIT, - None, + /*requires_openai_auth*/ None, "mock_provider", COMPACT_PROMPT, )?; @@ -257,7 +257,7 @@ async fn thread_compact_start_rejects_invalid_thread_id() -> Result<()> { &server.uri(), &BTreeMap::default(), AUTO_COMPACT_LIMIT, - None, + /*requires_openai_auth*/ None, "mock_provider", COMPACT_PROMPT, )?; @@ -293,7 +293,7 @@ async fn thread_compact_start_rejects_unknown_thread_id() -> Result<()> { &server.uri(), &BTreeMap::default(), AUTO_COMPACT_LIMIT, - None, + /*requires_openai_auth*/ None, "mock_provider", COMPACT_PROMPT, )?; diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index 23c9a6c6c2..99e33eb6b9 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -546,7 +546,7 @@ model = "gpt-old" ) .await??; let write: ConfigWriteResponse = to_response(write_resp)?; - let expected_file_path = AbsolutePathBuf::resolve_path_against_base("config.toml", codex_home)?; + let expected_file_path = AbsolutePathBuf::resolve_path_against_base("config.toml", codex_home); assert_eq!(write.status, WriteStatus::Ok); assert_eq!(write.file_path, expected_file_path); @@ -647,7 +647,7 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> { .await??; let batch_write: ConfigWriteResponse = to_response(batch_resp)?; assert_eq!(batch_write.status, WriteStatus::Ok); - let expected_file_path = AbsolutePathBuf::resolve_path_against_base("config.toml", codex_home)?; + let expected_file_path = AbsolutePathBuf::resolve_path_against_base("config.toml", codex_home); assert_eq!(batch_write.file_path, expected_file_path); let read_id = mcp 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 19158c68cb..bc311b31ca 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 @@ -58,27 +58,27 @@ async fn websocket_transport_routes_per_connection_handshake_and_responses() -> let mut ws1 = connect_websocket(bind_addr).await?; let mut ws2 = connect_websocket(bind_addr).await?; - send_initialize_request(&mut ws1, 1, "ws_client_one").await?; - let first_init = read_response_for_id(&mut ws1, 1).await?; + send_initialize_request(&mut ws1, /*id*/ 1, "ws_client_one").await?; + let first_init = read_response_for_id(&mut ws1, /*id*/ 1).await?; assert_eq!(first_init.id, RequestId::Integer(1)); // Initialize responses are request-scoped and must not leak to other // connections. assert_no_message(&mut ws2, Duration::from_millis(250)).await?; - send_config_read_request(&mut ws2, 2).await?; - let not_initialized = read_error_for_id(&mut ws2, 2).await?; + send_config_read_request(&mut ws2, /*id*/ 2).await?; + let not_initialized = read_error_for_id(&mut ws2, /*id*/ 2).await?; assert_eq!(not_initialized.error.message, "Not initialized"); - send_initialize_request(&mut ws2, 3, "ws_client_two").await?; - let second_init = read_response_for_id(&mut ws2, 3).await?; + send_initialize_request(&mut ws2, /*id*/ 3, "ws_client_two").await?; + let second_init = read_response_for_id(&mut ws2, /*id*/ 3).await?; assert_eq!(second_init.id, RequestId::Integer(3)); // Same request-id on different connections must route independently. - send_config_read_request(&mut ws1, 77).await?; - send_config_read_request(&mut ws2, 77).await?; - let ws1_config = read_response_for_id(&mut ws1, 77).await?; - let ws2_config = read_response_for_id(&mut ws2, 77).await?; + send_config_read_request(&mut ws1, /*id*/ 77).await?; + send_config_read_request(&mut ws2, /*id*/ 77).await?; + let ws1_config = read_response_for_id(&mut ws1, /*id*/ 77).await?; + let ws2_config = read_response_for_id(&mut ws2, /*id*/ 77).await?; assert_eq!(ws1_config.id, RequestId::Integer(77)); assert_eq!(ws2_config.id, RequestId::Integer(77)); @@ -108,8 +108,8 @@ async fn websocket_transport_serves_health_endpoints_on_same_listener() -> Resul assert_eq!(healthz.status(), StatusCode::OK); let mut ws = connect_websocket(bind_addr).await?; - send_initialize_request(&mut ws, 1, "ws_health_client").await?; - let init = read_response_for_id(&mut ws, 1).await?; + send_initialize_request(&mut ws, /*id*/ 1, "ws_health_client").await?; + let init = read_response_for_id(&mut ws, /*id*/ 1).await?; assert_eq!(init.id, RequestId::Integer(1)); process @@ -128,14 +128,14 @@ async fn websocket_transport_rejects_browser_origin_without_auth() -> Result<()> let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; let mut ws = connect_websocket(bind_addr).await?; - send_initialize_request(&mut ws, 1, "ws_loopback_client").await?; - let init = read_response_for_id(&mut ws, 1).await?; + send_initialize_request(&mut ws, /*id*/ 1, "ws_loopback_client").await?; + let init = read_response_for_id(&mut ws, /*id*/ 1).await?; assert_eq!(init.id, RequestId::Integer(1)); drop(ws); assert_websocket_connect_rejected_with_headers( bind_addr, - None, + /*bearer_token*/ None, Some("https://evil.example"), StatusCode::FORBIDDEN, ) @@ -165,12 +165,12 @@ async fn websocket_transport_rejects_missing_and_invalid_capability_tokens() -> let (mut process, bind_addr) = spawn_websocket_server_with_args(codex_home.path(), "ws://127.0.0.1:0", &auth_args).await?; - assert_websocket_connect_rejected(bind_addr, None).await?; + assert_websocket_connect_rejected(bind_addr, /*bearer_token*/ None).await?; assert_websocket_connect_rejected(bind_addr, Some("wrong-token")).await?; let mut ws = connect_websocket_with_bearer(bind_addr, Some("super-secret-token")).await?; - send_initialize_request(&mut ws, 1, "ws_auth_client").await?; - let init = read_response_for_id(&mut ws, 1).await?; + send_initialize_request(&mut ws, /*id*/ 1, "ws_auth_client").await?; + let init = read_response_for_id(&mut ws, /*id*/ 1).await?; assert_eq!(init.id, RequestId::Integer(1)); process @@ -266,8 +266,8 @@ async fn websocket_transport_verifies_signed_short_lived_bearer_tokens() -> Resu }), )?; let mut ws = connect_websocket_with_bearer(bind_addr, Some(valid_token.as_str())).await?; - send_initialize_request(&mut ws, 1, "ws_signed_auth_client").await?; - let init = read_response_for_id(&mut ws, 1).await?; + send_initialize_request(&mut ws, /*id*/ 1, "ws_signed_auth_client").await?; + let init = read_response_for_id(&mut ws, /*id*/ 1).await?; assert_eq!(init.id, RequestId::Integer(1)); process @@ -320,8 +320,8 @@ async fn websocket_transport_allows_unauthenticated_non_loopback_startup_by_defa spawn_websocket_server_with_args(codex_home.path(), "ws://0.0.0.0:0", &[]).await?; let mut ws = connect_websocket(bind_addr).await?; - send_initialize_request(&mut ws, 1, "ws_non_loopback_default_client").await?; - let init = read_response_for_id(&mut ws, 1).await?; + send_initialize_request(&mut ws, /*id*/ 1, "ws_non_loopback_default_client").await?; + let init = read_response_for_id(&mut ws, /*id*/ 1).await?; assert_eq!(init.id, RequestId::Integer(1)); process @@ -411,7 +411,7 @@ pub(super) async fn spawn_websocket_server_with_args( } pub(super) async fn connect_websocket(bind_addr: SocketAddr) -> Result { - connect_websocket_with_bearer(bind_addr, None).await + connect_websocket_with_bearer(bind_addr, /*bearer_token*/ None).await } pub(super) async fn connect_websocket_with_bearer( @@ -419,7 +419,7 @@ pub(super) async fn connect_websocket_with_bearer( bearer_token: Option<&str>, ) -> Result { let url = format!("ws://{}", connectable_bind_addr(bind_addr)); - let request = websocket_request(url.as_str(), bearer_token, None)?; + let request = websocket_request(url.as_str(), bearer_token, /*origin*/ None)?; let deadline = Instant::now() + Duration::from_secs(10); loop { match connect_async(request.clone()).await { @@ -441,7 +441,7 @@ async fn assert_websocket_connect_rejected( assert_websocket_connect_rejected_with_headers( bind_addr, bearer_token, - None, + /*origin*/ None, StatusCode::UNAUTHORIZED, ) .await diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs index 08156b9d95..591b70af09 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs @@ -156,16 +156,16 @@ async fn start_ctrl_c_restart_fixture(turn_delay: Duration) -> Result Result<()> { std::fs::write(&head_path, "ref: refs/heads/main\n")?; let mut mcp = initialized_mcp(&codex_home).await?; + let watch_id = "watch-head".to_string(); let watch_request_id = mcp .send_fs_watch_request(codex_app_server_protocol::FsWatchParams { + watch_id: watch_id.clone(), path: absolute_path(head_path.clone()), }) .await?; @@ -718,7 +716,7 @@ async fn fs_watch_file_reports_atomic_replace_events() -> Result<()> { assert_eq!( changed, FsChangedNotification { - watch_id: watch_response.watch_id, + watch_id, changed_paths: vec![absolute_path(head_path.clone())], } ); @@ -735,8 +733,10 @@ async fn fs_watch_allows_missing_file_targets() -> Result<()> { std::fs::create_dir_all(&git_dir)?; let mut mcp = initialized_mcp(&codex_home).await?; + let watch_id = "watch-fetch-head".to_string(); let watch_request_id = mcp .send_fs_watch_request(codex_app_server_protocol::FsWatchParams { + watch_id: watch_id.clone(), path: absolute_path(fetch_head.clone()), }) .await?; @@ -755,7 +755,7 @@ async fn fs_watch_allows_missing_file_targets() -> Result<()> { assert_eq!( changed, FsChangedNotification { - watch_id: watch_response.watch_id, + watch_id, changed_paths: vec![absolute_path(fetch_head.clone())], } ); @@ -770,7 +770,10 @@ async fn fs_watch_rejects_relative_paths() -> Result<()> { let mut mcp = initialized_mcp(&codex_home).await?; let watch_id = mcp - .send_raw_request("fs/watch", Some(json!({ "path": "relative-path" }))) + .send_raw_request( + "fs/watch", + Some(json!({ "watchId": "watch-relative", "path": "relative-path" })), + ) .await?; expect_error_message( &mut mcp, diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs new file mode 100644 index 0000000000..0a38f3b37e --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -0,0 +1,218 @@ +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use axum::Router; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::McpResourceContent; +use codex_app_server_protocol::McpResourceReadParams; +use codex_app_server_protocol::McpResourceReadResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_config::types::AuthCredentialsStoreMode; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use rmcp::handler::server::ServerHandler; +use rmcp::model::ProtocolVersion; +use rmcp::model::ReadResourceRequestParams; +use rmcp::model::ReadResourceResult; +use rmcp::model::ResourceContents; +use rmcp::model::ServerCapabilities; +use rmcp::model::ServerInfo; +use rmcp::service::RequestContext; +use rmcp::service::RoleServer; +use rmcp::transport::StreamableHttpServerConfig; +use rmcp::transport::StreamableHttpService; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use tempfile::TempDir; +use tokio::net::TcpListener; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); +const TEST_RESOURCE_URI: &str = "test://codex/resource"; +const TEST_BLOB_RESOURCE_URI: &str = "test://codex/resource.bin"; +const TEST_RESOURCE_BLOB: &str = "YmluYXJ5LXJlc291cmNl"; +const TEST_RESOURCE_TEXT: &str = "Resource body from the MCP server."; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_resource_read_returns_resource_contents() -> Result<()> { + let responses_server = responses::start_mock_server().await; + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let apps_server_url = format!("http://{addr}"); + + let mcp_service = StreamableHttpService::new( + move || Ok(ResourceAppsMcpServer), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + let router = Router::new().nest_service("/api/codex/apps", mcp_service); + let apps_server_handle = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + + let codex_home = TempDir::new()?; + let responses_server_uri = responses_server.uri(); + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "untrusted" +sandbox_mode = "read-only" + +model_provider = "mock_provider" +chatgpt_base_url = "{apps_server_url}" +mcp_oauth_credentials_store = "file" + +[features] +apps = true + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{responses_server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let read_request_id = mcp + .send_mcp_resource_read_request(McpResourceReadParams { + thread_id: thread.id, + server: "codex_apps".to_string(), + uri: TEST_RESOURCE_URI.to_string(), + }) + .await?; + let read_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_request_id)), + ) + .await??; + + assert_eq!( + to_response::(read_response)?, + McpResourceReadResponse { + contents: vec![ + McpResourceContent::Text { + uri: TEST_RESOURCE_URI.to_string(), + mime_type: Some("text/markdown".to_string()), + text: TEST_RESOURCE_TEXT.to_string(), + meta: None, + }, + McpResourceContent::Blob { + uri: TEST_BLOB_RESOURCE_URI.to_string(), + mime_type: Some("application/octet-stream".to_string()), + blob: TEST_RESOURCE_BLOB.to_string(), + meta: None, + }, + ], + } + ); + + apps_server_handle.abort(); + let _ = apps_server_handle.await; + Ok(()) +} + +#[tokio::test] +async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_mcp_resource_read_request(McpResourceReadParams { + thread_id: "00000000-0000-4000-8000-000000000000".to_string(), + server: "codex_apps".to_string(), + uri: TEST_RESOURCE_URI.to_string(), + }) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert!( + error.error.message.contains("thread not found"), + "expected thread-not-found error, got: {error:?}" + ); + + Ok(()) +} + +#[derive(Clone, Default)] +struct ResourceAppsMcpServer; + +impl ServerHandler for ResourceAppsMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + protocol_version: ProtocolVersion::V_2025_06_18, + capabilities: ServerCapabilities::builder().enable_resources().build(), + ..ServerInfo::default() + } + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + let uri = request.uri; + if uri != TEST_RESOURCE_URI { + return Err(rmcp::ErrorData::resource_not_found( + format!("resource not found: {uri}"), + None, + )); + } + + Ok(ReadResourceResult { + contents: vec![ + ResourceContents::TextResourceContents { + uri: TEST_RESOURCE_URI.to_string(), + mime_type: Some("text/markdown".to_string()), + text: TEST_RESOURCE_TEXT.to_string(), + meta: None, + }, + ResourceContents::BlobResourceContents { + uri: TEST_BLOB_RESOURCE_URI.to_string(), + mime_type: Some("application/octet-stream".to_string()), + blob: TEST_RESOURCE_BLOB.to_string(), + meta: None, + }, + ], + }) + } +} diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs index a784b34908..6326677250 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -31,7 +31,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as V2UserInput; -use codex_core::auth::AuthCredentialsStoreMode; +use codex_config::types::AuthCredentialsStoreMode; use core_test_support::responses; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs new file mode 100644 index 0000000000..e637be237d --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs @@ -0,0 +1,373 @@ +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml; +use axum::Router; +use codex_app_server_protocol::ListMcpServerStatusParams; +use codex_app_server_protocol::ListMcpServerStatusResponse; +use codex_app_server_protocol::McpServerStatusDetail; +use codex_app_server_protocol::RequestId; +use pretty_assertions::assert_eq; +use rmcp::handler::server::ServerHandler; +use rmcp::model::JsonObject; +use rmcp::model::ListResourceTemplatesResult; +use rmcp::model::ListResourcesResult; +use rmcp::model::ListToolsResult; +use rmcp::model::PaginatedRequestParams; +use rmcp::model::ServerCapabilities; +use rmcp::model::ServerInfo; +use rmcp::model::Tool; +use rmcp::model::ToolAnnotations; +use rmcp::service::RequestContext; +use rmcp::transport::StreamableHttpServerConfig; +use rmcp::transport::StreamableHttpService; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use serde_json::json; +use tempfile::TempDir; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +async fn mcp_server_status_list_returns_tools_for_hyphenated_server_names() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let (mcp_server_url, mcp_server_handle) = start_mcp_server("lookup").await?; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 1024, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + + let config_path = codex_home.path().join("config.toml"); + let mut config_toml = std::fs::read_to_string(&config_path)?; + config_toml.push_str(&format!( + r#" +[mcp_servers.some-server] +url = "{mcp_server_url}/mcp" +"# + )); + std::fs::write(config_path, config_toml)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_list_mcp_server_status_request(ListMcpServerStatusParams { + cursor: None, + limit: None, + detail: None, + }) + .await?; + let response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ListMcpServerStatusResponse = to_response(response)?; + + assert_eq!(response.next_cursor, None); + assert_eq!(response.data.len(), 1); + let status = &response.data[0]; + assert_eq!(status.name, "some-server"); + assert_eq!( + status.tools.keys().cloned().collect::>(), + BTreeSet::from(["lookup".to_string()]) + ); + + mcp_server_handle.abort(); + let _ = mcp_server_handle.await; + + Ok(()) +} + +#[derive(Clone)] +struct McpStatusServer { + tool_name: Arc, +} + +impl ServerHandler for McpStatusServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder().enable_tools().build(), + ..ServerInfo::default() + } + } + + async fn list_tools( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> Result { + let input_schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "additionalProperties": false + })) + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + + let mut tool = Tool::new( + Cow::Owned(self.tool_name.as_ref().clone()), + Cow::Borrowed("Look up test data."), + Arc::new(input_schema), + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + + Ok(ListToolsResult { + tools: vec![tool], + next_cursor: None, + meta: None, + }) + } +} + +#[derive(Clone)] +struct SlowInventoryServer { + tool_name: Arc, +} + +impl ServerHandler for SlowInventoryServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .build(), + ..ServerInfo::default() + } + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + let input_schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "additionalProperties": false + })) + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + + let mut tool = Tool::new( + Cow::Owned(self.tool_name.as_ref().clone()), + Cow::Borrowed("Look up test data."), + Arc::new(input_schema), + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + + Ok(ListToolsResult { + tools: vec![tool], + next_cursor: None, + meta: None, + }) + } + + async fn list_resources( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + tokio::time::sleep(Duration::from_secs(2)).await; + Ok(ListResourcesResult { + resources: Vec::new(), + next_cursor: None, + meta: None, + }) + } + + async fn list_resource_templates( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + tokio::time::sleep(Duration::from_secs(2)).await; + Ok(ListResourceTemplatesResult { + resource_templates: Vec::new(), + next_cursor: None, + meta: None, + }) + } +} + +#[tokio::test] +async fn mcp_server_status_list_tools_and_auth_only_skips_slow_inventory_calls() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let (mcp_server_url, mcp_server_handle) = start_slow_inventory_mcp_server("lookup").await?; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 1024, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + + let config_path = codex_home.path().join("config.toml"); + let mut config_toml = std::fs::read_to_string(&config_path)?; + config_toml.push_str(&format!( + r#" +[mcp_servers.some-server] +url = "{mcp_server_url}/mcp" +"# + )); + std::fs::write(config_path, config_toml)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_list_mcp_server_status_request(ListMcpServerStatusParams { + cursor: None, + limit: None, + detail: Some(McpServerStatusDetail::ToolsAndAuthOnly), + }) + .await?; + let response = timeout( + Duration::from_millis(500), + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ListMcpServerStatusResponse = to_response(response)?; + + assert_eq!(response.next_cursor, None); + assert_eq!(response.data.len(), 1); + let status = &response.data[0]; + assert_eq!(status.name, "some-server"); + assert_eq!( + status.tools.keys().cloned().collect::>(), + BTreeSet::from(["lookup".to_string()]) + ); + assert_eq!(status.resources, Vec::new()); + assert_eq!(status.resource_templates, Vec::new()); + + mcp_server_handle.abort(); + let _ = mcp_server_handle.await; + + Ok(()) +} + +#[tokio::test] +async fn mcp_server_status_list_does_not_duplicate_tools_for_sanitized_name_collisions() +-> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let (dash_server_url, dash_server_handle) = start_mcp_server("dash_lookup").await?; + let (underscore_server_url, underscore_server_handle) = + start_mcp_server("underscore_lookup").await?; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 1024, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + + let config_path = codex_home.path().join("config.toml"); + let mut config_toml = std::fs::read_to_string(&config_path)?; + config_toml.push_str(&format!( + r#" +[mcp_servers.some-server] +url = "{dash_server_url}/mcp" + +[mcp_servers.some_server] +url = "{underscore_server_url}/mcp" +"# + )); + std::fs::write(config_path, config_toml)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_list_mcp_server_status_request(ListMcpServerStatusParams { + cursor: None, + limit: None, + detail: None, + }) + .await?; + let response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ListMcpServerStatusResponse = to_response(response)?; + + assert_eq!(response.next_cursor, None); + assert_eq!(response.data.len(), 2); + let status_tools = response + .data + .iter() + .map(|status| (status.name.as_str(), status.tools.keys().count())) + .collect::>(); + assert_eq!( + status_tools, + BTreeMap::from([("some-server", 0), ("some_server", 0)]) + ); + + dash_server_handle.abort(); + let _ = dash_server_handle.await; + underscore_server_handle.abort(); + let _ = underscore_server_handle.await; + + Ok(()) +} + +async fn start_mcp_server(tool_name: &str) -> Result<(String, JoinHandle<()>)> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let tool_name = Arc::new(tool_name.to_string()); + let mcp_service = StreamableHttpService::new( + move || { + Ok(McpStatusServer { + tool_name: Arc::clone(&tool_name), + }) + }, + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + let router = Router::new().nest_service("/mcp", mcp_service); + + let handle = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + + Ok((format!("http://{addr}"), handle)) +} + +async fn start_slow_inventory_mcp_server(tool_name: &str) -> Result<(String, JoinHandle<()>)> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let tool_name = Arc::new(tool_name.to_string()); + let mcp_service = StreamableHttpService::new( + move || { + Ok(SlowInventoryServer { + tool_name: Arc::clone(&tool_name), + }) + }, + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + let router = Router::new().nest_service("/mcp", mcp_service); + + let handle = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + + Ok((format!("http://{addr}"), handle)) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index b4e24ebe28..6cd67daa58 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -14,7 +14,9 @@ mod experimental_api; mod experimental_feature_list; mod fs; mod initialize; +mod mcp_resource; mod mcp_server_elicitation; +mod mcp_server_status; mod model_list; mod output_schema; mod plan_item; diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index eba8905a77..bd5c61ce0b 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -56,8 +56,10 @@ fn model_from_preset(preset: &ModelPreset) -> Model { fn expected_visible_models() -> Vec { // Filter by supported_in_api to support testing with both ChatGPT and non-ChatGPT auth modes. - let mut presets = - ModelPreset::filter_by_auth(codex_core::test_support::all_model_presets().clone(), false); + let mut presets = ModelPreset::filter_by_auth( + codex_core::test_support::all_model_presets().clone(), + /*chatgpt_mode*/ false, + ); // Mirror `ModelsManager::build_available_models()` default selection after auth filtering. ModelPreset::mark_default_by_picker_visibility(&mut presets); diff --git a/codex-rs/app-server/tests/suite/v2/plan_item.rs b/codex-rs/app-server/tests/suite/v2/plan_item.rs index 0ed93cbeae..97e67fa090 100644 --- a/codex-rs/app-server/tests/suite/v2/plan_item.rs +++ b/codex-rs/app-server/tests/suite/v2/plan_item.rs @@ -59,7 +59,7 @@ async fn plan_mode_uses_proposed_plan_block_for_plan_item() -> Result<()> { let turn = start_plan_mode_turn(&mut mcp).await?; let (_, completed_items, plan_deltas, turn_completed) = collect_turn_notifications(&mut mcp).await?; - wait_for_responses_request_count(&server, 1).await?; + wait_for_responses_request_count(&server, /*expected_count*/ 1).await?; assert_eq!(turn_completed.turn.id, turn.id); assert_eq!(turn_completed.turn.status, TurnStatus::Completed); @@ -116,7 +116,7 @@ async fn plan_mode_without_proposed_plan_does_not_emit_plan_item() -> Result<()> let _turn = start_plan_mode_turn(&mut mcp).await?; let (_, completed_items, plan_deltas, _) = collect_turn_notifications(&mut mcp).await?; - wait_for_responses_request_count(&server, 1).await?; + wait_for_responses_request_count(&server, /*expected_count*/ 1).await?; let has_plan_item = completed_items .iter() diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 8c597d94a3..a3bea53172 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -25,7 +25,7 @@ use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::RequestId; -use codex_core::auth::AuthCredentialsStoreMode; +use codex_config::types::AuthCredentialsStoreMode; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; @@ -118,7 +118,7 @@ async fn plugin_install_returns_invalid_request_for_not_available_plugin() -> Re "sample-plugin", "./sample-plugin", Some("NOT_AVAILABLE"), - None, + /*auth_policy*/ None, )?; write_plugin_source(repo_root.path(), "sample-plugin", &[])?; let marketplace_path = @@ -217,8 +217,8 @@ async fn plugin_install_force_remote_sync_enables_remote_plugin_before_local_ins "debug", "sample-plugin", "./sample-plugin", - None, - None, + /*install_policy*/ None, + /*auth_policy*/ None, )?; write_plugin_source(repo_root.path(), "sample-plugin", &[])?; let marketplace_path = @@ -286,8 +286,8 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> { "debug", "sample-plugin", "./sample-plugin", - None, - None, + /*install_policy*/ None, + /*auth_policy*/ None, )?; write_plugin_source(repo_root.path(), "sample-plugin", &[])?; let marketplace_path = @@ -401,8 +401,8 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> { "debug", "sample-plugin", "./sample-plugin", - None, - None, + /*install_policy*/ None, + /*auth_policy*/ None, )?; write_plugin_source(repo_root.path(), "sample-plugin", &["alpha", "beta"])?; let marketplace_path = @@ -481,7 +481,7 @@ async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> { "debug", "sample-plugin", "./sample-plugin", - None, + /*install_policy*/ None, Some("ON_USE"), )?; write_plugin_source( @@ -542,8 +542,8 @@ async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests "debug", "sample-plugin", "./sample-plugin", - None, - None, + /*install_policy*/ None, + /*auth_policy*/ None, )?; write_plugin_source(repo_root.path(), "sample-plugin", &[])?; std::fs::write( 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 ce3d4f5286..8bc4a8598e 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -15,7 +15,7 @@ use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginSource; use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::RequestId; -use codex_core::auth::AuthCredentialsStoreMode; +use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::set_project_trust_level; use codex_protocol::config_types::TrustLevel; use codex_utils_absolute_path::AbsolutePathBuf; @@ -931,7 +931,8 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; wait_for_path_exists(&marker_path).await?; - wait_for_remote_plugin_request_count(&server, "/plugins/list", 1).await?; + wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1) + .await?; let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: None, @@ -957,7 +958,8 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { .collect::>(), vec![("linear@openai-curated".to_string(), true, true)] ); - wait_for_remote_plugin_request_count(&server, "/plugins/list", 1).await?; + wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1) + .await?; } let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; @@ -969,7 +971,7 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { } tokio::time::sleep(Duration::from_millis(250)).await; - wait_for_remote_plugin_request_count(&server, "/plugins/list", 1).await?; + wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1).await?; Ok(()) } @@ -1029,7 +1031,7 @@ async fn plugin_list_uses_warmed_featured_plugin_ids_cache_on_first_request() -> let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - wait_for_featured_plugin_request_count(&server, 1).await?; + wait_for_featured_plugin_request_count(&server, /*expected_count*/ 1).await?; let request_id = mcp .send_plugin_list_request(PluginListParams { diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 977ceb2691..115a04f0f5 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -23,7 +23,7 @@ use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::RequestId; -use codex_core::auth::AuthCredentialsStoreMode; +use codex_config::types::AuthCredentialsStoreMode; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs index 6e0938aa53..00fabe4832 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs @@ -11,7 +11,7 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginUninstallParams; use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::RequestId; -use codex_core::auth::AuthCredentialsStoreMode; +use codex_config::types::AuthCredentialsStoreMode; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; diff --git a/codex-rs/app-server/tests/suite/v2/rate_limits.rs b/codex-rs/app-server/tests/suite/v2/rate_limits.rs index 51048202f6..203d664940 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -10,7 +10,7 @@ use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; use codex_app_server_protocol::RequestId; -use codex_core::auth::AuthCredentialsStoreMode; +use codex_config::types::AuthCredentialsStoreMode; use codex_protocol::account::PlanType as AccountPlanType; use pretty_assertions::assert_eq; use serde_json::json; 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 e8a39efa64..317155da70 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -99,7 +99,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { codex_home.path(), &responses_server.uri(), realtime_server.uri(), - true, + /*realtime_enabled*/ true, )?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -137,7 +137,9 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { assert!(started.session_id.is_some()); assert_eq!(started.version, RealtimeConversationVersion::V2); - let startup_context_request = realtime_server.wait_for_request(0, 0).await; + let startup_context_request = realtime_server + .wait_for_request(/*connection_index*/ 0, /*request_index*/ 0) + .await; assert_eq!( startup_context_request.body_json()["type"].as_str(), Some("session.update") @@ -306,7 +308,7 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> { codex_home.path(), &responses_server.uri(), realtime_server.uri(), - true, + /*realtime_enabled*/ true, )?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -378,7 +380,7 @@ async fn realtime_conversation_requires_feature_flag() -> Result<()> { codex_home.path(), &responses_server.uri(), realtime_server.uri(), - false, + /*realtime_enabled*/ false, )?; let mut mcp = McpProcess::new(codex_home.path()).await?; diff --git a/codex-rs/app-server/tests/suite/v2/review.rs b/codex-rs/app-server/tests/suite/v2/review.rs index 6febbaf52a..d56b9318e3 100644 --- a/codex-rs/app-server/tests/suite/v2/review.rs +++ b/codex-rs/app-server/tests/suite/v2/review.rs @@ -149,7 +149,7 @@ async fn review_start_exec_approval_item_id_matches_command_execution_item() -> "rev-parse".to_string(), "HEAD".to_string(), ], - None, + /*workdir*/ None, Some(5000), "review-call-1", )?, diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index a81c87bd4e..1137f09848 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -24,8 +24,10 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; -use codex_core::auth::AuthCredentialsStoreMode; -use codex_core::auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use codex_config::types::AuthCredentialsStoreMode; +use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use core_test_support::responses; +use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; @@ -38,6 +40,11 @@ use wiremock::ResponseTemplate; use wiremock::matchers::method; use wiremock::matchers::path; +use super::analytics::assert_basic_thread_initialized_event; +use super::analytics::enable_analytics_capture; +use super::analytics::thread_initialized_event; +use super::analytics::wait_for_analytics_payload; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -53,7 +60,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { "2025-01-05T12:00:00Z", preview, Some("mock_provider"), - None, + /*git_info*/ None, )?; let original_path = codex_home @@ -107,6 +114,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { ); assert_ne!(thread.id, conversation_id); + assert_eq!(thread.forked_from_id, Some(conversation_id.clone())); assert_eq!(thread.preview, preview); assert_eq!(thread.model_provider, "mock_provider"); assert_eq!(thread.status, ThreadStatus::Idle); @@ -177,6 +185,214 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_fork_honors_explicit_null_thread_instructions() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response_mock = + responses::mount_sse_sequence(&server, vec![body.clone(), body.clone(), body]).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let config_path = codex_home.path().join("config.toml"); + let mut config_toml = std::fs::read_to_string(&config_path)?; + let first_table_index = config_toml + .find("\n[") + .expect("test config must include a table header"); + config_toml.insert_str( + first_table_index, + "\ndeveloper_instructions = \"Config developer instructions sentinel\"\n", + ); + std::fs::write(config_path, config_toml)?; + + let conversation_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let disabled_instruction_config = json!({ + "include_permissions_instructions": false, + "include_apps_instructions": false, + "include_environment_context": false, + "features.apps": false, + "features.plugins": false, + "features.codex_hooks": false, + "skills.bundled.enabled": false, + }); + + let fork_params = [ + ( + json!({ + "threadId": conversation_id.clone(), + "config": disabled_instruction_config.clone(), + }), + /*expect_instructions*/ true, + ), + ( + json!({ + "threadId": conversation_id.clone(), + "config": disabled_instruction_config.clone(), + "baseInstructions": null, + "developerInstructions": null, + }), + /*expect_instructions*/ false, + ), + ]; + + let mut forked_thread_ids = Vec::new(); + for (params, _expect_instructions) in fork_params { + let fork_id = mcp.send_raw_request("thread/fork", Some(params)).await?; + let fork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), + ) + .await??; + let ThreadForkResponse { thread, .. } = to_response::(fork_resp)?; + forked_thread_ids.push(thread.id.clone()); + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![UserInput::Text { + text: "continue".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??; + let _: TurnStartResponse = to_response::(turn_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + } + + let refork_id = mcp + .send_raw_request( + "thread/fork", + Some(json!({ + "threadId": forked_thread_ids[1].clone(), + "config": disabled_instruction_config.clone(), + })), + ) + .await?; + let refork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(refork_id)), + ) + .await??; + let ThreadForkResponse { thread, .. } = to_response::(refork_resp)?; + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![UserInput::Text { + text: "continue again".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??; + let _: TurnStartResponse = to_response::(turn_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 3); + for (request, expect_instructions) in requests.into_iter().zip([true, false, false]) { + let payload = request.body_json(); + assert_eq!( + payload.get("instructions").is_some(), + expect_instructions, + "unexpected instructions field in payload: {payload:?}" + ); + let developer_texts = request.message_input_texts("developer"); + assert_eq!( + developer_texts + .iter() + .any(|text| { text.contains("Config developer instructions sentinel") }), + expect_instructions, + "unexpected config developer instruction presence: {developer_texts:?}" + ); + assert!( + developer_texts.iter().all(|text| !text.is_empty()), + "did not expect empty developer instruction messages: {developer_texts:?}" + ); + } + + Ok(()) +} + +#[tokio::test] +async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + /*general_analytics_enabled*/ true, + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let conversation_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: conversation_id, + ..Default::default() + }) + .await?; + let fork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), + ) + .await??; + let ThreadForkResponse { thread, .. } = to_response::(fork_resp)?; + + let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; + let event = thread_initialized_event(&payload)?; + assert_basic_thread_initialized_event(event, &thread.id, "mock-model", "forked"); + Ok(()) +} + #[tokio::test] async fn thread_fork_rejects_unmaterialized_thread() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -249,6 +465,7 @@ async fn thread_fork_surfaces_cloud_requirements_load_errors() -> Result<()> { codex_home.path(), &model_server.uri(), &chatgpt_base_url, + /*general_analytics_enabled*/ false, )?; write_chatgpt_auth( codex_home.path(), @@ -267,7 +484,7 @@ async fn thread_fork_surfaces_cloud_requirements_load_errors() -> Result<()> { "2025-01-05T12:00:00Z", "Saved user message", Some("mock_provider"), - None, + /*git_info*/ None, )?; let refresh_token_url = format!("{}/oauth/token", server.uri()); @@ -331,7 +548,7 @@ async fn thread_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<() "2025-01-05T12:00:00Z", preview, Some("mock_provider"), - None, + /*git_info*/ None, )?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -509,7 +726,13 @@ fn create_config_toml_with_chatgpt_base_url( codex_home: &Path, server_uri: &str, chatgpt_base_url: &str, + general_analytics_enabled: bool, ) -> std::io::Result<()> { + let general_analytics_toml = if general_analytics_enabled { + "\ngeneral_analytics = true".to_string() + } else { + String::new() + }; let config_toml = codex_home.join("config.toml"); std::fs::write( config_toml, @@ -522,6 +745,9 @@ chatgpt_base_url = "{chatgpt_base_url}" model_provider = "mock_provider" +[features] +{general_analytics_toml} + [model_providers.mock_provider] name = "Mock provider for test" base_url = "{server_uri}/v1" diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 2b5d1fe8a0..62faba9c17 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -58,7 +58,16 @@ async fn list_threads( source_kinds: Option>, archived: Option, ) -> Result { - list_threads_with_sort(mcp, cursor, limit, providers, source_kinds, None, archived).await + list_threads_with_sort( + mcp, + cursor, + limit, + providers, + source_kinds, + /*sort_key*/ None, + archived, + ) + .await } async fn list_threads_with_sort( @@ -110,7 +119,7 @@ where &ts_rfc, preview, Some(provider_for_index(i)), - None, + /*git_info*/ None, )?); } Ok(ids) @@ -172,11 +181,11 @@ async fn thread_list_basic_empty() -> Result<()> { data, next_cursor, .. } = list_threads( &mut mcp, - None, + /*cursor*/ None, Some(10), Some(vec!["mock_provider".to_string()]), - None, - None, + /*source_kinds*/ None, + /*archived*/ None, ) .await?; assert!(data.is_empty()); @@ -256,7 +265,7 @@ async fn thread_list_reports_system_error_idle_flag_after_failed_turn() -> Resul let ThreadListResponse { data, .. } = list_threads( &mut mcp, - None, + /*cursor*/ None, Some(10), Some(vec!["mock_provider".to_string()]), Some(vec![ @@ -264,7 +273,7 @@ async fn thread_list_reports_system_error_idle_flag_after_failed_turn() -> Resul ThreadSourceKind::Cli, ThreadSourceKind::VsCode, ]), - None, + /*archived*/ None, ) .await?; let listed = data @@ -323,7 +332,7 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { "2025-01-02T12:00:00Z", "Hello", Some("mock_provider"), - None, + /*git_info*/ None, )?; let _b = create_fake_rollout( codex_home.path(), @@ -331,7 +340,7 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { "2025-01-01T13:00:00Z", "Hello", Some("mock_provider"), - None, + /*git_info*/ None, )?; let _c = create_fake_rollout( codex_home.path(), @@ -339,7 +348,7 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { "2025-01-01T12:00:00Z", "Hello", Some("mock_provider"), - None, + /*git_info*/ None, )?; let mut mcp = init_mcp(codex_home.path()).await?; @@ -350,11 +359,11 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { next_cursor: cursor1, } = list_threads( &mut mcp, - None, + /*cursor*/ None, Some(2), Some(vec!["mock_provider".to_string()]), - None, - None, + /*source_kinds*/ None, + /*archived*/ None, ) .await?; assert_eq!(data1.len(), 2); @@ -380,8 +389,8 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { Some(cursor1), Some(2), Some(vec!["mock_provider".to_string()]), - None, - None, + /*source_kinds*/ None, + /*archived*/ None, ) .await?; assert!(data2.len() <= 2); @@ -413,7 +422,7 @@ async fn thread_list_respects_provider_filter() -> Result<()> { "2025-01-02T10:00:00Z", "X", Some("mock_provider"), - None, + /*git_info*/ None, )?; // mock_provider let _b = create_fake_rollout( codex_home.path(), @@ -421,7 +430,7 @@ async fn thread_list_respects_provider_filter() -> Result<()> { "2025-01-02T11:00:00Z", "X", Some("other_provider"), - None, + /*git_info*/ None, )?; let mut mcp = init_mcp(codex_home.path()).await?; @@ -431,11 +440,11 @@ async fn thread_list_respects_provider_filter() -> Result<()> { data, next_cursor, .. } = list_threads( &mut mcp, - None, + /*cursor*/ None, Some(10), Some(vec!["other_provider".to_string()]), - None, - None, + /*source_kinds*/ None, + /*archived*/ None, ) .await?; assert_eq!(data.len(), 1); @@ -465,7 +474,7 @@ async fn thread_list_respects_cwd_filter() -> Result<()> { "2025-01-02T10:00:00Z", "filtered", Some("mock_provider"), - None, + /*git_info*/ None, )?; let unfiltered_id = create_fake_rollout( codex_home.path(), @@ -473,7 +482,7 @@ async fn thread_list_respects_cwd_filter() -> Result<()> { "2025-01-02T11:00:00Z", "unfiltered", Some("mock_provider"), - None, + /*git_info*/ None, )?; let target_cwd = codex_home.path().join("target-cwd"); @@ -535,7 +544,7 @@ sqlite = true "2025-01-02T10:00:00Z", "match: needle", Some("mock_provider"), - None, + /*git_info*/ None, )?; let _non_match = create_fake_rollout( codex_home.path(), @@ -543,7 +552,7 @@ sqlite = true "2025-01-02T11:00:00Z", "no hit here", Some("mock_provider"), - None, + /*git_info*/ None, )?; let newer_match = create_fake_rollout( codex_home.path(), @@ -551,7 +560,7 @@ sqlite = true "2025-01-02T12:00:00Z", "needle suffix", Some("mock_provider"), - None, + /*git_info*/ None, )?; // `thread/list` only applies `search_term` on the sqlite path. In this test we @@ -560,7 +569,9 @@ sqlite = true let state_db = codex_state::StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()) .await?; - state_db.mark_backfill_complete(None).await?; + state_db + .mark_backfill_complete(/*last_watermark*/ None) + .await?; let mut mcp = init_mcp(codex_home.path()).await?; let request_id = mcp @@ -602,7 +613,7 @@ async fn thread_list_empty_source_kinds_defaults_to_interactive_only() -> Result "2025-02-01T10:00:00Z", "CLI", Some("mock_provider"), - None, + /*git_info*/ None, )?; let exec_id = create_fake_rollout_with_source( codex_home.path(), @@ -610,7 +621,7 @@ async fn thread_list_empty_source_kinds_defaults_to_interactive_only() -> Result "2025-02-01T11:00:00Z", "Exec", Some("mock_provider"), - None, + /*git_info*/ None, CoreSessionSource::Exec, )?; @@ -620,11 +631,11 @@ async fn thread_list_empty_source_kinds_defaults_to_interactive_only() -> Result data, next_cursor, .. } = list_threads( &mut mcp, - None, + /*cursor*/ None, Some(10), Some(vec!["mock_provider".to_string()]), Some(Vec::new()), - None, + /*archived*/ None, ) .await?; @@ -648,7 +659,7 @@ async fn thread_list_filters_by_source_kind_subagent_thread_spawn() -> Result<() "2025-02-01T10:00:00Z", "CLI", Some("mock_provider"), - None, + /*git_info*/ None, )?; let parent_thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?; @@ -658,7 +669,7 @@ async fn thread_list_filters_by_source_kind_subagent_thread_spawn() -> Result<() "2025-02-01T11:00:00Z", "SubAgent", Some("mock_provider"), - None, + /*git_info*/ None, CoreSessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, @@ -674,11 +685,11 @@ async fn thread_list_filters_by_source_kind_subagent_thread_spawn() -> Result<() data, next_cursor, .. } = list_threads( &mut mcp, - None, + /*cursor*/ None, Some(10), Some(vec!["mock_provider".to_string()]), Some(vec![ThreadSourceKind::SubAgentThreadSpawn]), - None, + /*archived*/ None, ) .await?; @@ -704,7 +715,7 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> { "2025-02-02T09:00:00Z", "Review", Some("mock_provider"), - None, + /*git_info*/ None, CoreSessionSource::SubAgent(SubAgentSource::Review), )?; let compact_id = create_fake_rollout_with_source( @@ -713,7 +724,7 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> { "2025-02-02T10:00:00Z", "Compact", Some("mock_provider"), - None, + /*git_info*/ None, CoreSessionSource::SubAgent(SubAgentSource::Compact), )?; let spawn_id = create_fake_rollout_with_source( @@ -722,7 +733,7 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> { "2025-02-02T11:00:00Z", "Spawn", Some("mock_provider"), - None, + /*git_info*/ None, CoreSessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, @@ -737,7 +748,7 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> { "2025-02-02T12:00:00Z", "Other", Some("mock_provider"), - None, + /*git_info*/ None, CoreSessionSource::SubAgent(SubAgentSource::Other("custom".to_string())), )?; @@ -745,11 +756,11 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> { let review = list_threads( &mut mcp, - None, + /*cursor*/ None, Some(10), Some(vec!["mock_provider".to_string()]), Some(vec![ThreadSourceKind::SubAgentReview]), - None, + /*archived*/ None, ) .await?; let review_ids: Vec<_> = review @@ -761,11 +772,11 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> { let compact = list_threads( &mut mcp, - None, + /*cursor*/ None, Some(10), Some(vec!["mock_provider".to_string()]), Some(vec![ThreadSourceKind::SubAgentCompact]), - None, + /*archived*/ None, ) .await?; let compact_ids: Vec<_> = compact @@ -777,11 +788,11 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> { let spawn = list_threads( &mut mcp, - None, + /*cursor*/ None, Some(10), Some(vec!["mock_provider".to_string()]), Some(vec![ThreadSourceKind::SubAgentThreadSpawn]), - None, + /*archived*/ None, ) .await?; let spawn_ids: Vec<_> = spawn.data.iter().map(|thread| thread.id.as_str()).collect(); @@ -789,11 +800,11 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> { let other = list_threads( &mut mcp, - None, + /*cursor*/ None, Some(10), Some(vec!["mock_provider".to_string()]), Some(vec![ThreadSourceKind::SubAgentOther]), - None, + /*archived*/ None, ) .await?; let other_ids: Vec<_> = other.data.iter().map(|thread| thread.id.as_str()).collect(); @@ -812,7 +823,7 @@ async fn thread_list_fetches_until_limit_or_exhausted() -> Result<()> { // paging past the first two pages to reach the desired count. create_fake_rollouts( codex_home.path(), - 24, + /*count*/ 24, |i| { if i < 16 { "skip_provider" @@ -820,7 +831,16 @@ async fn thread_list_fetches_until_limit_or_exhausted() -> Result<()> { "target_provider" } }, - |i| timestamp_at(2025, 3, 30 - i as u32, 12, 0, 0), + |i| { + timestamp_at( + /*year*/ 2025, + /*month*/ 3, + 30 - i as u32, + /*hour*/ 12, + /*minute*/ 0, + /*second*/ 0, + ) + }, "Hello", )?; @@ -832,11 +852,11 @@ async fn thread_list_fetches_until_limit_or_exhausted() -> Result<()> { data, next_cursor, .. } = list_threads( &mut mcp, - None, + /*cursor*/ None, Some(8), Some(vec!["target_provider".to_string()]), - None, - None, + /*source_kinds*/ None, + /*archived*/ None, ) .await?; assert_eq!( @@ -864,12 +884,19 @@ async fn thread_list_enforces_max_limit() -> Result<()> { create_fake_rollouts( codex_home.path(), - 105, + /*count*/ 105, |_| "mock_provider", |i| { let month = 5 + (i / 28); let day = (i % 28) + 1; - timestamp_at(2025, month as u32, day as u32, 0, 0, 0) + timestamp_at( + /*year*/ 2025, + month as u32, + day as u32, + /*hour*/ 0, + /*minute*/ 0, + /*second*/ 0, + ) }, "Hello", )?; @@ -880,11 +907,11 @@ async fn thread_list_enforces_max_limit() -> Result<()> { data, next_cursor, .. } = list_threads( &mut mcp, - None, + /*cursor*/ None, Some(200), Some(vec!["mock_provider".to_string()]), - None, - None, + /*source_kinds*/ None, + /*archived*/ None, ) .await?; assert_eq!( @@ -909,7 +936,7 @@ async fn thread_list_stops_when_not_enough_filtered_results_exist() -> Result<() // ensure the server exhausts pagination without looping forever. create_fake_rollouts( codex_home.path(), - 22, + /*count*/ 22, |i| { if i < 15 { "skip_provider" @@ -917,7 +944,16 @@ async fn thread_list_stops_when_not_enough_filtered_results_exist() -> Result<() "target_provider" } }, - |i| timestamp_at(2025, 4, 28 - i as u32, 8, 0, 0), + |i| { + timestamp_at( + /*year*/ 2025, + /*month*/ 4, + 28 - i as u32, + /*hour*/ 8, + /*minute*/ 0, + /*second*/ 0, + ) + }, "Hello", )?; @@ -929,11 +965,11 @@ async fn thread_list_stops_when_not_enough_filtered_results_exist() -> Result<() data, next_cursor, .. } = list_threads( &mut mcp, - None, + /*cursor*/ None, Some(10), Some(vec!["target_provider".to_string()]), - None, - None, + /*source_kinds*/ None, + /*archived*/ None, ) .await?; assert_eq!( @@ -977,11 +1013,11 @@ async fn thread_list_includes_git_info() -> Result<()> { let ThreadListResponse { data, .. } = list_threads( &mut mcp, - None, + /*cursor*/ None, Some(10), Some(vec!["mock_provider".to_string()]), - None, - None, + /*source_kinds*/ None, + /*archived*/ None, ) .await?; let thread = data @@ -1013,7 +1049,7 @@ async fn thread_list_default_sorts_by_created_at() -> Result<()> { "2025-01-02T12:00:00Z", "Hello", Some("mock_provider"), - None, + /*git_info*/ None, )?; let id_b = create_fake_rollout( codex_home.path(), @@ -1021,7 +1057,7 @@ async fn thread_list_default_sorts_by_created_at() -> Result<()> { "2025-01-01T13:00:00Z", "Hello", Some("mock_provider"), - None, + /*git_info*/ None, )?; let id_c = create_fake_rollout( codex_home.path(), @@ -1029,19 +1065,19 @@ async fn thread_list_default_sorts_by_created_at() -> Result<()> { "2025-01-01T12:00:00Z", "Hello", Some("mock_provider"), - None, + /*git_info*/ None, )?; let mut mcp = init_mcp(codex_home.path()).await?; let ThreadListResponse { data, .. } = list_threads_with_sort( &mut mcp, - None, + /*cursor*/ None, Some(10), Some(vec!["mock_provider".to_string()]), - None, - None, - None, + /*source_kinds*/ None, + /*sort_key*/ None, + /*archived*/ None, ) .await?; @@ -1062,7 +1098,7 @@ async fn thread_list_sort_updated_at_orders_by_mtime() -> Result<()> { "2025-01-01T10:00:00Z", "Hello", Some("mock_provider"), - None, + /*git_info*/ None, )?; let id_mid = create_fake_rollout( codex_home.path(), @@ -1070,7 +1106,7 @@ async fn thread_list_sort_updated_at_orders_by_mtime() -> Result<()> { "2025-01-01T11:00:00Z", "Hello", Some("mock_provider"), - None, + /*git_info*/ None, )?; let id_new = create_fake_rollout( codex_home.path(), @@ -1078,7 +1114,7 @@ async fn thread_list_sort_updated_at_orders_by_mtime() -> Result<()> { "2025-01-01T12:00:00Z", "Hello", Some("mock_provider"), - None, + /*git_info*/ None, )?; set_rollout_mtime( @@ -1098,12 +1134,12 @@ async fn thread_list_sort_updated_at_orders_by_mtime() -> Result<()> { let ThreadListResponse { data, .. } = list_threads_with_sort( &mut mcp, - None, + /*cursor*/ None, Some(10), Some(vec!["mock_provider".to_string()]), - None, + /*source_kinds*/ None, Some(ThreadSortKey::UpdatedAt), - None, + /*archived*/ None, ) .await?; @@ -1124,7 +1160,7 @@ async fn thread_list_updated_at_paginates_with_cursor() -> Result<()> { "2025-02-01T10:00:00Z", "Hello", Some("mock_provider"), - None, + /*git_info*/ None, )?; let id_b = create_fake_rollout( codex_home.path(), @@ -1132,7 +1168,7 @@ async fn thread_list_updated_at_paginates_with_cursor() -> Result<()> { "2025-02-01T11:00:00Z", "Hello", Some("mock_provider"), - None, + /*git_info*/ None, )?; let id_c = create_fake_rollout( codex_home.path(), @@ -1140,7 +1176,7 @@ async fn thread_list_updated_at_paginates_with_cursor() -> Result<()> { "2025-02-01T12:00:00Z", "Hello", Some("mock_provider"), - None, + /*git_info*/ None, )?; set_rollout_mtime( @@ -1164,12 +1200,12 @@ async fn thread_list_updated_at_paginates_with_cursor() -> Result<()> { .. } = list_threads_with_sort( &mut mcp, - None, + /*cursor*/ None, Some(2), Some(vec!["mock_provider".to_string()]), - None, + /*source_kinds*/ None, Some(ThreadSortKey::UpdatedAt), - None, + /*archived*/ None, ) .await?; let ids_page1: Vec<_> = page1.iter().map(|thread| thread.id.as_str()).collect(); @@ -1185,9 +1221,9 @@ async fn thread_list_updated_at_paginates_with_cursor() -> Result<()> { Some(cursor1), Some(2), Some(vec!["mock_provider".to_string()]), - None, + /*source_kinds*/ None, Some(ThreadSortKey::UpdatedAt), - None, + /*archived*/ None, ) .await?; let ids_page2: Vec<_> = page2.iter().map(|thread| thread.id.as_str()).collect(); @@ -1208,7 +1244,7 @@ async fn thread_list_created_at_tie_breaks_by_uuid() -> Result<()> { "2025-02-01T10:00:00Z", "Hello", Some("mock_provider"), - None, + /*git_info*/ None, )?; let id_b = create_fake_rollout( codex_home.path(), @@ -1216,18 +1252,18 @@ async fn thread_list_created_at_tie_breaks_by_uuid() -> Result<()> { "2025-02-01T10:00:00Z", "Hello", Some("mock_provider"), - None, + /*git_info*/ None, )?; let mut mcp = init_mcp(codex_home.path()).await?; let ThreadListResponse { data, .. } = list_threads( &mut mcp, - None, + /*cursor*/ None, Some(10), Some(vec!["mock_provider".to_string()]), - None, - None, + /*source_kinds*/ None, + /*archived*/ None, ) .await?; @@ -1251,7 +1287,7 @@ async fn thread_list_updated_at_tie_breaks_by_uuid() -> Result<()> { "2025-02-01T10:00:00Z", "Hello", Some("mock_provider"), - None, + /*git_info*/ None, )?; let id_b = create_fake_rollout( codex_home.path(), @@ -1259,7 +1295,7 @@ async fn thread_list_updated_at_tie_breaks_by_uuid() -> Result<()> { "2025-02-01T11:00:00Z", "Hello", Some("mock_provider"), - None, + /*git_info*/ None, )?; let updated_at = "2025-02-03T00:00:00Z"; @@ -1276,12 +1312,12 @@ async fn thread_list_updated_at_tie_breaks_by_uuid() -> Result<()> { let ThreadListResponse { data, .. } = list_threads_with_sort( &mut mcp, - None, + /*cursor*/ None, Some(10), Some(vec!["mock_provider".to_string()]), - None, + /*source_kinds*/ None, Some(ThreadSortKey::UpdatedAt), - None, + /*archived*/ None, ) .await?; @@ -1305,7 +1341,7 @@ async fn thread_list_updated_at_uses_mtime() -> Result<()> { "2025-02-01T10:00:00Z", "Hello", Some("mock_provider"), - None, + /*git_info*/ None, )?; set_rollout_mtime( @@ -1317,12 +1353,12 @@ async fn thread_list_updated_at_uses_mtime() -> Result<()> { let ThreadListResponse { data, .. } = list_threads_with_sort( &mut mcp, - None, + /*cursor*/ None, Some(10), Some(vec!["mock_provider".to_string()]), - None, + /*source_kinds*/ None, Some(ThreadSortKey::UpdatedAt), - None, + /*archived*/ None, ) .await?; @@ -1351,7 +1387,7 @@ async fn thread_list_archived_filter() -> Result<()> { "2025-03-01T10:00:00Z", "Active", Some("mock_provider"), - None, + /*git_info*/ None, )?; let archived_id = create_fake_rollout( codex_home.path(), @@ -1359,7 +1395,7 @@ async fn thread_list_archived_filter() -> Result<()> { "2025-03-01T09:00:00Z", "Archived", Some("mock_provider"), - None, + /*git_info*/ None, )?; let archived_dir = codex_home.path().join(ARCHIVED_SESSIONS_SUBDIR); @@ -1376,11 +1412,11 @@ async fn thread_list_archived_filter() -> Result<()> { let ThreadListResponse { data, .. } = list_threads( &mut mcp, - None, + /*cursor*/ None, Some(10), Some(vec!["mock_provider".to_string()]), - None, - None, + /*source_kinds*/ None, + /*archived*/ None, ) .await?; assert_eq!(data.len(), 1); @@ -1388,10 +1424,10 @@ async fn thread_list_archived_filter() -> Result<()> { let ThreadListResponse { data, .. } = list_threads( &mut mcp, - None, + /*cursor*/ None, Some(10), Some(vec!["mock_provider".to_string()]), - None, + /*source_kinds*/ None, Some(true), ) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs index 680e70f2d8..8bf9a8a9aa 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs @@ -19,10 +19,10 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus; use codex_core::ARCHIVED_SESSIONS_SUBDIR; -use codex_core::state_db::reconcile_rollout; use codex_git_utils::GitSha; use codex_protocol::ThreadId; use codex_protocol::protocol::GitInfo as RolloutGitInfo; +use codex_rollout::state_db::reconcile_rollout; use codex_state::StateRuntime; use pretty_assertions::assert_eq; use serde_json::Value; @@ -184,7 +184,7 @@ async fn thread_metadata_update_repairs_missing_sqlite_row_for_stored_thread() - "2025-01-05T12:00:00Z", preview, Some("mock_provider"), - None, + /*git_info*/ None, )?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -237,7 +237,7 @@ async fn thread_metadata_update_repairs_loaded_thread_without_resetting_summary( "2025-01-06T08:30:00Z", preview, Some("mock_provider"), - None, + /*git_info*/ None, )?; let thread_uuid = ThreadId::from_string(&thread_id)?; let rollout_path = rollout_path(codex_home.path(), "2025-01-06T08-30-00", &thread_id); @@ -245,10 +245,10 @@ async fn thread_metadata_update_repairs_loaded_thread_without_resetting_summary( Some(&state_db), rollout_path.as_path(), "mock_provider", - None, + /*builder*/ None, &[], - None, - None, + /*archived_only*/ None, + /*new_thread_memory_mode*/ None, ) .await; @@ -317,7 +317,7 @@ async fn thread_metadata_update_repairs_missing_sqlite_row_for_archived_thread() "2025-01-06T08:30:00Z", preview, Some("mock_provider"), - None, + /*git_info*/ None, )?; let archived_dir = codex_home.path().join(ARCHIVED_SESSIONS_SUBDIR); @@ -430,7 +430,9 @@ async fn thread_metadata_update_can_clear_stored_git_fields() -> Result<()> { async fn init_state_db(codex_home: &Path) -> Result> { let state_db = StateRuntime::init(codex_home.to_path_buf(), "mock_provider".into()).await?; - state_db.mark_backfill_complete(None).await?; + state_db + .mark_backfill_complete(/*last_watermark*/ None) + .await?; Ok(state_db) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs b/codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs index a05518fc3f..4ec7344d6c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs @@ -43,14 +43,14 @@ async fn thread_name_updated_broadcasts_for_loaded_threads() -> Result<()> { send_request( &mut ws1, "thread/resume", - 10, + /*id*/ 10, Some(serde_json::to_value(ThreadResumeParams { thread_id: conversation_id.clone(), ..Default::default() })?), ) .await?; - let resume_resp: JSONRPCResponse = read_response_for_id(&mut ws1, 10).await?; + let resume_resp: JSONRPCResponse = read_response_for_id(&mut ws1, /*id*/ 10).await?; let resume: ThreadResumeResponse = to_response::(resume_resp)?; assert_eq!(resume.thread.id, conversation_id); @@ -58,15 +58,19 @@ async fn thread_name_updated_broadcasts_for_loaded_threads() -> Result<()> { send_request( &mut ws1, "thread/name/set", - 11, + /*id*/ 11, Some(serde_json::to_value(ThreadSetNameParams { thread_id: conversation_id.clone(), name: renamed.to_string(), })?), ) .await?; - let (rename_resp, ws1_notification) = - read_response_and_notification_for_method(&mut ws1, 11, "thread/name/updated").await?; + let (rename_resp, ws1_notification) = read_response_and_notification_for_method( + &mut ws1, + /*id*/ 11, + "thread/name/updated", + ) + .await?; let _: ThreadSetNameResponse = to_response::(rename_resp)?; assert_thread_name_updated(ws1_notification, &conversation_id, renamed)?; @@ -105,15 +109,19 @@ async fn thread_name_updated_broadcasts_for_not_loaded_threads() -> Result<()> { send_request( &mut ws1, "thread/name/set", - 20, + /*id*/ 20, Some(serde_json::to_value(ThreadSetNameParams { thread_id: conversation_id.clone(), name: renamed.to_string(), })?), ) .await?; - let (rename_resp, ws1_notification) = - read_response_and_notification_for_method(&mut ws1, 20, "thread/name/updated").await?; + let (rename_resp, ws1_notification) = read_response_and_notification_for_method( + &mut ws1, + /*id*/ 20, + "thread/name/updated", + ) + .await?; let _: ThreadSetNameResponse = to_response::(rename_resp)?; assert_thread_name_updated(ws1_notification, &conversation_id, renamed)?; @@ -135,11 +143,11 @@ async fn thread_name_updated_broadcasts_for_not_loaded_threads() -> Result<()> { } async fn initialize_both_clients(ws1: &mut WsClient, ws2: &mut WsClient) -> Result<()> { - send_initialize_request(ws1, 1, "ws_client_one").await?; - timeout(DEFAULT_READ_TIMEOUT, read_response_for_id(ws1, 1)).await??; + send_initialize_request(ws1, /*id*/ 1, "ws_client_one").await?; + timeout(DEFAULT_READ_TIMEOUT, read_response_for_id(ws1, /*id*/ 1)).await??; - send_initialize_request(ws2, 2, "ws_client_two").await?; - timeout(DEFAULT_READ_TIMEOUT, read_response_for_id(ws2, 2)).await??; + send_initialize_request(ws2, /*id*/ 2, "ws_client_two").await?; + timeout(DEFAULT_READ_TIMEOUT, read_response_for_id(ws2, /*id*/ 2)).await??; Ok(()) } @@ -151,7 +159,7 @@ fn create_rollout(codex_home: &std::path::Path, filename_ts: &str) -> Result Result<()> { .map(|elem| serde_json::to_value(elem).expect("serialize text element")) .collect(), Some("mock_provider"), - None, + /*git_info*/ None, )?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -112,7 +114,7 @@ async fn thread_read_can_include_turns() -> Result<()> { .map(|elem| serde_json::to_value(elem).expect("serialize text element")) .collect(), Some("mock_provider"), - None, + /*git_info*/ None, )?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -152,6 +154,56 @@ async fn thread_read_can_include_turns() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_read_returns_forked_from_id_for_forked_threads() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + vec![], + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: conversation_id.clone(), + ..Default::default() + }) + .await?; + let fork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), + ) + .await??; + let ThreadForkResponse { thread: forked, .. } = to_response::(fork_resp)?; + + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: forked.id, + include_turns: false, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { thread } = to_response::(read_resp)?; + + assert_eq!(thread.forked_from_id, Some(conversation_id)); + + Ok(()) +} + #[tokio::test] async fn thread_read_loaded_thread_returns_precomputed_path_before_materialization() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -215,7 +267,7 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> { preview, vec![], Some("mock_provider"), - None, + /*git_info*/ None, )?; let mut mcp = McpProcess::new(codex_home.path()).await?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 4443abd6ec..4c92b61c25 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -38,8 +38,8 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; -use codex_core::auth::AuthCredentialsStoreMode; -use codex_core::auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use codex_config::types::AuthCredentialsStoreMode; +use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_protocol::ThreadId; use codex_protocol::config_types::Personality; use codex_protocol::models::ContentItem; @@ -70,6 +70,11 @@ use wiremock::ResponseTemplate; use wiremock::matchers::method; use wiremock::matchers::path; +use super::analytics::assert_basic_thread_initialized_event; +use super::analytics::enable_analytics_capture; +use super::analytics::thread_initialized_event; +use super::analytics::wait_for_analytics_payload; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); 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."; @@ -150,6 +155,51 @@ async fn thread_resume_rejects_unmaterialized_thread() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + /*general_analytics_enabled*/ true, + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Vec::new(), + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; + + let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; + let event = thread_initialized_event(&payload)?; + assert_basic_thread_initialized_event(event, &thread.id, "gpt-5.2-codex", "resumed"); + Ok(()) +} + #[tokio::test] async fn thread_resume_returns_rollout_history() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -171,7 +221,7 @@ async fn thread_resume_returns_rollout_history() -> Result<()> { .map(|elem| serde_json::to_value(elem).expect("serialize text element")) .collect(), Some("mock_provider"), - None, + /*git_info*/ None, )?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -327,6 +377,7 @@ stream_max_retries = 0 agent_role: None, model_provider: Some("mock_provider".to_string()), base_instructions: None, + developer_instructions: None, dynamic_tools: None, memory_mode: None, }; @@ -368,7 +419,9 @@ stream_max_retries = 0 )?; let state_db = StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()).await?; - state_db.mark_backfill_complete(None).await?; + state_db + .mark_backfill_complete(/*last_watermark*/ None) + .await?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -429,7 +482,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "Saved user message", Vec::new(), Some("mock_provider"), - None, + /*git_info*/ None, )?; let rollout_file_path = rollout_path(codex_home.path(), filename_ts, &conversation_id); let persisted_rollout = std::fs::read_to_string(&rollout_file_path)?; @@ -440,6 +493,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }))?, @@ -1022,7 +1076,7 @@ async fn thread_resume_replays_pending_command_execution_request_approval() -> R "-c".to_string(), "print(42)".to_string(), ], - None, + /*workdir*/ None, Some(5000), "call-1", )?, @@ -1143,7 +1197,7 @@ async fn thread_resume_replays_pending_command_execution_request_approval() -> R primary.read_stream_until_notification_message("turn/completed"), ) .await??; - wait_for_responses_request_count(&server, 3).await?; + wait_for_responses_request_count(&server, /*expected_count*/ 3).await?; Ok(()) } @@ -1309,7 +1363,7 @@ async fn thread_resume_replays_pending_file_change_request_approval() -> Result< primary.read_stream_until_notification_message("turn/completed"), ) .await??; - wait_for_responses_request_count(&server, 3).await?; + wait_for_responses_request_count(&server, /*expected_count*/ 3).await?; Ok(()) } @@ -1447,6 +1501,7 @@ async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> { codex_home.path(), &model_server.uri(), &chatgpt_base_url, + /*general_analytics_enabled*/ false, )?; write_chatgpt_auth( codex_home.path(), @@ -1465,7 +1520,7 @@ async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> { "Saved user message", Vec::new(), Some("mock_provider"), - None, + /*git_info*/ None, )?; let refresh_token_url = format!("{}/oauth/token", server.uri()); let mut mcp = McpProcess::new_with_env( @@ -1825,6 +1880,7 @@ model_provider = "mock_provider" [features] personality = true +general_analytics = true [model_providers.mock_provider] name = "Mock provider for test" @@ -1841,7 +1897,13 @@ fn create_config_toml_with_chatgpt_base_url( codex_home: &std::path::Path, server_uri: &str, chatgpt_base_url: &str, + general_analytics_enabled: bool, ) -> std::io::Result<()> { + let general_analytics_toml = if general_analytics_enabled { + "\ngeneral_analytics = true".to_string() + } else { + String::new() + }; let config_toml = codex_home.join("config.toml"); std::fs::write( config_toml, @@ -1856,6 +1918,7 @@ model_provider = "mock_provider" [features] personality = true +{general_analytics_toml} [model_providers.mock_provider] name = "Mock provider for test" @@ -1933,7 +1996,7 @@ fn setup_rollout_fixture(codex_home: &Path, server_uri: &str) -> Result ) .await??; let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + let (shell_command, expected_output) = current_shell_output_command("hello from bang")?; let shell_id = mcp .send_thread_shell_command_request(ThreadShellCommandParams { thread_id: thread.id.clone(), - command: "printf 'hello from bang\\n'".to_string(), + command: shell_command, }) .await?; let shell_resp: JSONRPCResponse = timeout( @@ -81,7 +83,7 @@ async fn thread_shell_command_runs_as_standalone_turn_and_persists_history() -> .await??; let _: ThreadShellCommandResponse = to_response::(shell_resp)?; - let started = wait_for_command_execution_started(&mut mcp, None).await?; + let started = wait_for_command_execution_started(&mut mcp, /*expected_id*/ None).await?; let ThreadItem::CommandExecution { id, source, status, .. } = &started.item @@ -93,7 +95,10 @@ async fn thread_shell_command_runs_as_standalone_turn_and_persists_history() -> assert_eq!(status, &CommandExecutionStatus::InProgress); let delta = wait_for_command_execution_output_delta(&mut mcp, &command_id).await?; - assert_eq!(delta.delta, "hello from bang\n"); + assert_eq!( + delta.delta.trim_end_matches(['\r', '\n']), + expected_output.trim_end_matches(['\r', '\n']) + ); let completed = wait_for_command_execution_completed(&mut mcp, Some(&command_id)).await?; let ThreadItem::CommandExecution { @@ -110,7 +115,7 @@ async fn thread_shell_command_runs_as_standalone_turn_and_persists_history() -> assert_eq!(id, &command_id); assert_eq!(source, &CommandExecutionSource::UserShell); assert_eq!(status, &CommandExecutionStatus::Completed); - assert_eq!(aggregated_output.as_deref(), Some("hello from bang\n")); + assert_eq!(aggregated_output.as_deref(), Some(expected_output.as_str())); assert_eq!(*exit_code, Some(0)); timeout( @@ -147,7 +152,7 @@ async fn thread_shell_command_runs_as_standalone_turn_and_persists_history() -> }; assert_eq!(source, &CommandExecutionSource::UserShell); assert_eq!(status, &CommandExecutionStatus::Completed); - assert_eq!(aggregated_output.as_deref(), Some("hello from bang\n")); + assert_eq!(aggregated_output.as_deref(), Some(expected_output.as_str())); Ok(()) } @@ -167,7 +172,7 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> { "-c".to_string(), "print(42)".to_string(), ], - None, + /*workdir*/ None, Some(5000), "call-approve", )?, @@ -196,6 +201,7 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> { ) .await??; let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + let (shell_command, expected_output) = current_shell_output_command("active turn bang")?; let turn_id = mcp .send_turn_start_request(TurnStartParams { @@ -240,7 +246,7 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> { let shell_id = mcp .send_thread_shell_command_request(ThreadShellCommandParams { thread_id: thread.id.clone(), - command: "printf 'active turn bang\\n'".to_string(), + command: shell_command, }) .await?; let shell_resp: JSONRPCResponse = timeout( @@ -269,7 +275,7 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> { unreachable!("helper returns command execution item"); }; assert_eq!(source, &CommandExecutionSource::UserShell); - assert_eq!(aggregated_output.as_deref(), Some("active turn bang\n")); + assert_eq!(aggregated_output.as_deref(), Some(expected_output.as_str())); mcp.send_response( request_id, @@ -309,7 +315,7 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> { source: CommandExecutionSource::UserShell, aggregated_output, .. - } if aggregated_output.as_deref() == Some("active turn bang\n") + } if aggregated_output.as_deref() == Some(expected_output.as_str()) ) }), "expected active-turn shell command to be persisted on the existing turn" @@ -318,6 +324,24 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> { Ok(()) } +fn current_shell_output_command(text: &str) -> Result<(String, String)> { + let command_and_output = match default_user_shell().name() { + "powershell" => { + let escaped_text = text.replace('\'', "''"); + ( + format!("Write-Output '{escaped_text}'"), + format!("{text}\r\n"), + ) + } + "cmd" => (format!("echo {text}"), format!("{text}\r\n")), + _ => { + let quoted_text = shlex::try_quote(text)?; + (format!("printf '%s\\n' {quoted_text}"), format!("{text}\n")) + } + }; + Ok(command_and_output) +} + async fn wait_for_command_execution_started( mcp: &mut McpProcess, expected_id: Option<&str>, @@ -345,7 +369,7 @@ async fn wait_for_command_execution_started_by_source( expected_source: CommandExecutionSource, ) -> Result { loop { - let started = wait_for_command_execution_started(mcp, None).await?; + let started = wait_for_command_execution_started(mcp, /*expected_id*/ None).await?; let ThreadItem::CommandExecution { source, .. } = &started.item else { continue; }; 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 1a73444bda..ed17c2be25 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -4,21 +4,24 @@ use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::McpServerStartupState; use codex_app_server_protocol::McpServerStatusUpdatedNotification; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxMode; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartedNotification; use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::ThreadStatusChangedNotification; -use codex_core::auth::AuthCredentialsStoreMode; -use codex_core::auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::set_project_trust_level; +use codex_git_utils::resolve_root_git_project_for_trust; +use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::openai_models::ReasoningEffort; @@ -26,6 +29,7 @@ use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; use std::path::Path; +use std::time::Duration; use tempfile::TempDir; use tokio::time::timeout; use wiremock::Mock; @@ -34,6 +38,11 @@ use wiremock::ResponseTemplate; use wiremock::matchers::method; use wiremock::matchers::path; +use super::analytics::assert_basic_thread_initialized_event; +use super::analytics::enable_analytics_capture; +use super::analytics::thread_initialized_event; +use super::analytics::wait_for_analytics_payload; + const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] @@ -42,7 +51,7 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), &server.uri())?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; // Start server and initialize. let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -153,12 +162,79 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + /*general_analytics_enabled*/ true, + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(resp)?; + + let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; + assert_eq!(payload["events"].as_array().expect("events array").len(), 1); + let event = thread_initialized_event(&payload)?; + assert_basic_thread_initialized_event(event, &thread.id, "mock-model", "new"); + Ok(()) +} + +#[tokio::test] +async fn thread_start_does_not_track_thread_initialized_analytics_without_feature() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + /*general_analytics_enabled*/ false, + )?; + enable_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let _ = to_response::(resp)?; + + let payload = wait_for_analytics_payload(&server, Duration::from_millis(250)).await; + assert!( + payload.is_err(), + "thread analytics should be gated off when general_analytics is disabled" + ); + Ok(()) +} + #[tokio::test] async fn thread_start_respects_project_config_from_cwd() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), &server.uri())?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; let workspace = TempDir::new()?; let project_config_dir = workspace.path().join(".codex"); @@ -199,7 +275,7 @@ async fn thread_start_accepts_flex_service_tier() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), &server.uri())?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -227,7 +303,7 @@ async fn thread_start_accepts_metrics_service_name() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), &server.uri())?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -254,7 +330,7 @@ async fn thread_start_accepts_metrics_service_name() -> Result<()> { async fn thread_start_ephemeral_remains_pathless() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), &server.uri())?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -455,6 +531,7 @@ async fn thread_start_surfaces_cloud_requirements_load_errors() -> Result<()> { codex_home.path(), &model_server.uri(), &chatgpt_base_url, + /*general_analytics_enabled*/ false, )?; write_chatgpt_auth( codex_home.path(), @@ -510,16 +587,227 @@ async fn thread_start_surfaces_cloud_requirements_load_errors() -> Result<()> { Ok(()) } -// Helper to create a config.toml pointing at the mock model server. -fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { +#[tokio::test] +async fn thread_start_with_elevated_sandbox_trusts_project_and_followup_loads_project_config() +-> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let workspace = TempDir::new()?; + let project_config_dir = workspace.path().join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + std::fs::write( + project_config_dir.join("config.toml"), + r#" +model_reasoning_effort = "high" +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let first_request = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + sandbox: Some(SandboxMode::WorkspaceWrite), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_request)), + ) + .await??; + + let second_request = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + ..Default::default() + }) + .await?; + let second_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_request)), + ) + .await??; + let ThreadStartResponse { + approval_policy, + reasoning_effort, + .. + } = to_response::(second_response)?; + + assert_eq!(approval_policy, AskForApproval::OnRequest); + assert_eq!(reasoning_effort, Some(ReasoningEffort::High)); + + let config_toml = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + let trusted_root = resolve_root_git_project_for_trust(workspace.path()) + .await + .unwrap_or_else(|| workspace.path().to_path_buf()); + assert!(config_toml.contains(&persisted_trust_path(&trusted_root))); + assert!(config_toml.contains("trust_level = \"trusted\"")); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_with_nested_git_cwd_trusts_repo_root() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let repo_root = TempDir::new()?; + std::fs::create_dir(repo_root.path().join(".git"))?; + let nested = repo_root.path().join("nested/project"); + std::fs::create_dir_all(&nested)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(nested.display().to_string()), + sandbox: Some(SandboxMode::WorkspaceWrite), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let config_toml = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + let trusted_root = resolve_root_git_project_for_trust(&nested) + .await + .expect("git root should resolve"); + assert!(config_toml.contains(&persisted_trust_path(&trusted_root))); + assert!(!config_toml.contains(&persisted_trust_path(&nested))); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_with_read_only_sandbox_does_not_persist_project_trust() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let workspace = TempDir::new()?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let config_toml = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(!config_toml.contains("trust_level = \"trusted\"")); + assert!(!config_toml.contains(&workspace.path().display().to_string())); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_skips_trust_write_when_project_is_already_trusted() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let workspace = TempDir::new()?; + let project_config_dir = workspace.path().join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + std::fs::write( + project_config_dir.join("config.toml"), + r#" +model_reasoning_effort = "high" +"#, + )?; + set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; + let config_before = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + sandbox: Some(SandboxMode::WorkspaceWrite), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadStartResponse { + approval_policy, + reasoning_effort, + .. + } = to_response::(response)?; + + assert_eq!(approval_policy, AskForApproval::OnRequest); + assert_eq!(reasoning_effort, Some(ReasoningEffort::High)); + + let config_after = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert_eq!(config_after, config_before); + + Ok(()) +} + +fn create_config_toml_without_approval_policy( + codex_home: &Path, + server_uri: &str, +) -> std::io::Result<()> { + create_config_toml_with_optional_approval_policy( + codex_home, server_uri, /*approval_policy*/ None, + ) +} + +fn persisted_trust_path(project_path: &Path) -> String { + let project_path = + std::fs::canonicalize(project_path).unwrap_or_else(|_| project_path.to_path_buf()); + let project_path = project_path.display().to_string(); + + if let Some(project_path) = project_path.strip_prefix(r"\\?\UNC\") { + return format!(r"\\{project_path}"); + } + + project_path + .strip_prefix(r"\\?\") + .unwrap_or(&project_path) + .to_string() +} + +fn create_config_toml_with_optional_approval_policy( + codex_home: &Path, + server_uri: &str, + approval_policy: Option<&str>, +) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); + let approval_policy = approval_policy + .map(|policy| format!("approval_policy = \"{policy}\"\n")) + .unwrap_or_default(); std::fs::write( config_toml, format!( r#" model = "mock-model" -approval_policy = "never" -sandbox_mode = "read-only" +{approval_policy}sandbox_mode = "read-only" model_provider = "mock_provider" @@ -538,7 +826,13 @@ fn create_config_toml_with_chatgpt_base_url( codex_home: &Path, server_uri: &str, chatgpt_base_url: &str, + general_analytics_enabled: bool, ) -> std::io::Result<()> { + let general_analytics_toml = if general_analytics_enabled { + "\ngeneral_analytics = true".to_string() + } else { + String::new() + }; let config_toml = codex_home.join("config.toml"); std::fs::write( config_toml, @@ -551,6 +845,9 @@ chatgpt_base_url = "{chatgpt_base_url}" model_provider = "mock_provider" +[features] +{general_analytics_toml} + [model_providers.mock_provider] name = "Mock provider for test" base_url = "{server_uri}/v1" diff --git a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs index 6427ae7ce0..5808f0fe79 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs @@ -219,7 +219,7 @@ async fn thread_unsubscribe_during_turn_interrupts_turn_and_emits_thread_closed( wait_for_responses_request_count_to_stabilize( &server, - 1, + /*expected_count*/ 1, std::time::Duration::from_millis(200), ) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 8f944dca07..2850c7b74f 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -50,7 +50,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { "call_sleep", )?]) .await; - create_config_toml(&codex_home, &server.uri(), "never")?; + create_config_toml(&codex_home, &server.uri(), "never", "danger-full-access")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -124,14 +124,11 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { #[tokio::test] async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<()> { - #[cfg(target_os = "windows")] let shell_command = vec![ - "powershell".to_string(), - "-Command".to_string(), - "Start-Sleep -Seconds 10".to_string(), + "python3".to_string(), + "-c".to_string(), + "print(42)".to_string(), ]; - #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); @@ -143,10 +140,10 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() shell_command.clone(), Some(&working_directory), Some(10_000), - "call_sleep_approval", + "call_python_approval", )?]) .await; - create_config_toml(&codex_home, &server.uri(), "untrusted")?; + create_config_toml(&codex_home, &server.uri(), "untrusted", "read-only")?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -168,7 +165,7 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), input: vec![V2UserInput::Text { - text: "run sleep".to_string(), + text: "run python".to_string(), text_elements: Vec::new(), }], cwd: Some(working_directory), @@ -190,7 +187,7 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() let ServerRequest::CommandExecutionRequestApproval { request_id, params } = request else { panic!("expected CommandExecutionRequestApproval request"); }; - assert_eq!(params.item_id, "call_sleep_approval"); + assert_eq!(params.item_id, "call_python_approval"); assert_eq!(params.thread_id, thread.id); assert_eq!(params.turn_id, turn.id); @@ -242,6 +239,7 @@ fn create_config_toml( codex_home: &std::path::Path, server_uri: &str, approval_policy: &str, + sandbox_mode: &str, ) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); std::fs::write( @@ -250,7 +248,7 @@ fn create_config_toml( r#" model = "mock-model" approval_policy = "{approval_policy}" -sandbox_mode = "danger-full-access" +sandbox_mode = "{sandbox_mode}" model_provider = "mock_provider" 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 8d7ca02613..70c388a19d 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -43,7 +43,7 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as V2UserInput; -use codex_core::config::ConfigToml; +use codex_config::config_toml::ConfigToml; use codex_core::personality_migration::PERSONALITY_MIGRATION_FILENAME; use codex_features::FEATURES; use codex_features::Feature; @@ -152,6 +152,120 @@ async fn turn_start_sends_originator_header() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_honors_explicit_null_thread_instructions() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response_mock = responses::mount_sse_sequence(&server, vec![body.clone(), body]).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never", &BTreeMap::new())?; + let config_path = codex_home.path().join("config.toml"); + let mut config_toml = std::fs::read_to_string(&config_path)?; + let first_table_index = config_toml + .find("\n[") + .expect("test config must include a table header"); + config_toml.insert_str( + first_table_index, + "\ndeveloper_instructions = \"Config developer instructions sentinel\"\n", + ); + std::fs::write(config_path, config_toml)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let disabled_instruction_config = json!({ + "include_permissions_instructions": false, + "include_apps_instructions": false, + "include_environment_context": false, + "features.apps": false, + "features.plugins": false, + "features.codex_hooks": false, + "skills.bundled.enabled": false, + }); + + let thread_start_params = [ + ( + json!({ + "model": "mock-model", + "config": disabled_instruction_config.clone(), + }), + /*expect_instructions*/ true, + ), + ( + json!({ + "model": "mock-model", + "config": disabled_instruction_config.clone(), + "baseInstructions": null, + "developerInstructions": null, + }), + /*expect_instructions*/ false, + ), + ]; + + for (params, _expect_instructions) in thread_start_params { + let thread_req = mcp.send_raw_request("thread/start", Some(params)).await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + } + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + for (request, expect_instructions) in requests.into_iter().zip([true, false]) { + let payload = request.body_json(); + assert_eq!( + payload.get("instructions").is_some(), + expect_instructions, + "unexpected instructions field in payload: {payload:?}" + ); + let developer_texts = request.message_input_texts("developer"); + assert_eq!( + developer_texts + .iter() + .any(|text| { text.contains("Config developer instructions sentinel") }), + expect_instructions, + "unexpected config developer instruction presence: {developer_texts:?}" + ); + assert!( + developer_texts.iter().all(|text| !text.is_empty()), + "did not expect empty developer instruction messages: {developer_texts:?}" + ); + } + + Ok(()) +} + #[tokio::test] async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; @@ -888,7 +1002,7 @@ async fn turn_start_uses_migrated_pragmatic_personality_without_override_v2() -> "2025-01-01T00:00:00Z", "history user message", Some("mock_provider"), - None, + /*git_info*/ None, )?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -1031,7 +1145,7 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { "-c".to_string(), "print(42)".to_string(), ], - None, + /*workdir*/ None, Some(5000), "call1", )?, @@ -1042,7 +1156,7 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { "-c".to_string(), "print(42)".to_string(), ], - None, + /*workdir*/ None, Some(5000), "call2", )?, @@ -1186,7 +1300,7 @@ async fn turn_start_exec_approval_decline_v2() -> Result<()> { "-c".to_string(), "print(42)".to_string(), ], - None, + /*workdir*/ None, Some(5000), "call-decline", )?, @@ -1331,14 +1445,14 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { let responses = vec![ create_shell_command_sse_response( vec!["echo".to_string(), "first".to_string(), "turn".to_string()], - None, + /*workdir*/ None, Some(5000), "call-first", )?, create_final_assistant_message_sse_response("done first")?, create_shell_command_sse_response( vec!["echo".to_string(), "second".to_string(), "turn".to_string()], - None, + /*workdir*/ None, Some(5000), "call-second", )?, @@ -2523,6 +2637,67 @@ async fn command_execution_notifications_include_process_id() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_with_elevated_override_does_not_persist_project_trust() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::from([(Feature::Personality, true)]), + )?; + + let workspace = TempDir::new()?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_request = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + ..Default::default() + }) + .await?; + let thread_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_request)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_response)?; + + let turn_request = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + cwd: Some(workspace.path().to_path_buf()), + sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_request)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let config_toml = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(!config_toml.contains("trust_level = \"trusted\"")); + assert!(!config_toml.contains(&workspace.path().display().to_string())); + + Ok(()) +} + // Helper to create a config.toml pointing at the mock model server. fn create_config_toml( codex_home: &Path, 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 c8ae882e23..105ae54542 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 @@ -71,7 +71,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { format!("while [ ! -f '{release_marker_escaped}' ]; do sleep 0.01; done"); let response = create_shell_command_sse_response( vec!["/bin/sh".to_string(), "-c".to_string(), wait_for_interrupt], - None, + /*workdir*/ None, Some(5000), "call-zsh-fork", )?; @@ -197,7 +197,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> { "-c".to_string(), "print(42)".to_string(), ], - None, + /*workdir*/ None, Some(5000), "call-zsh-fork-decline", )?, @@ -332,7 +332,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> { "-c".to_string(), "print(42)".to_string(), ], - None, + /*workdir*/ None, Some(5000), "call-zsh-fork-cancel", )?]; @@ -805,7 +805,7 @@ fn find_test_zsh_path() -> Result> { ); return Ok(None); } - match core_test_support::fetch_dotslash_file(&dotslash_zsh, None) { + match core_test_support::fetch_dotslash_file(&dotslash_zsh, /*dotslash_cache*/ None) { Ok(path) => return Ok(Some(path)), Err(error) => { eprintln!("failed to fetch vendored zsh via dotslash: {error:#}"); diff --git a/codex-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs b/codex-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs index b3cbfdf325..a0466a459b 100644 --- a/codex-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs +++ b/codex-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs @@ -26,7 +26,7 @@ async fn windows_sandbox_setup_start_emits_completion_notification() -> Result<( codex_home.path(), &server.uri(), &BTreeMap::new(), - 500_000, + /*auto_compact_limit*/ 500_000, Some(false), "mock_provider", "compact prompt", diff --git a/codex-rs/apply-patch/Cargo.toml b/codex-rs/apply-patch/Cargo.toml index 2fad46c2af..ba4fa5e48e 100644 --- a/codex-rs/apply-patch/Cargo.toml +++ b/codex-rs/apply-patch/Cargo.toml @@ -17,8 +17,11 @@ workspace = true [dependencies] anyhow = { workspace = true } +codex-exec-server = { workspace = true } +codex-utils-absolute-path = { workspace = true } similar = { workspace = true } thiserror = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt"] } tree-sitter = { workspace = true } tree-sitter-bash = { workspace = true } diff --git a/codex-rs/apply-patch/src/invocation.rs b/codex-rs/apply-patch/src/invocation.rs index 7623aef80d..3b0db2fa9c 100644 --- a/codex-rs/apply-patch/src/invocation.rs +++ b/codex-rs/apply-patch/src/invocation.rs @@ -2,6 +2,8 @@ use std::collections::HashMap; use std::path::Path; use std::sync::LazyLock; +use codex_exec_server::ExecutorFileSystem; +use codex_utils_absolute_path::AbsolutePathBuf; use tree_sitter::Parser; use tree_sitter::Query; use tree_sitter::QueryCursor; @@ -129,7 +131,11 @@ pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch { /// cwd must be an absolute path so that we can resolve relative paths in the /// patch. -pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApplyPatchVerified { +pub async fn maybe_parse_apply_patch_verified( + argv: &[String], + cwd: &AbsolutePathBuf, + fs: &dyn ExecutorFileSystem, +) -> MaybeApplyPatchVerified { // Detect a raw patch body passed directly as the command or as the body of a shell // script. In these cases, report an explicit error rather than applying the patch. if let [body] = argv @@ -151,24 +157,20 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp }) => { let effective_cwd = workdir .as_ref() - .map(|dir| { - let path = Path::new(dir); - if path.is_absolute() { - path.to_path_buf() - } else { - cwd.join(path) - } - }) - .unwrap_or_else(|| cwd.to_path_buf()); + .map(|dir| cwd.join(Path::new(dir))) + .unwrap_or_else(|| cwd.clone()); let mut changes = HashMap::new(); for hunk in hunks { let path = hunk.resolve_path(&effective_cwd); match hunk { Hunk::AddFile { contents, .. } => { - changes.insert(path, ApplyPatchFileChange::Add { content: contents }); + changes.insert( + path.into_path_buf(), + ApplyPatchFileChange::Add { content: contents }, + ); } Hunk::DeleteFile { .. } => { - let content = match std::fs::read_to_string(&path) { + let content = match fs.read_file_text(&path).await { Ok(content) => content, Err(e) => { return MaybeApplyPatchVerified::CorrectnessError( @@ -179,7 +181,10 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp ); } }; - changes.insert(path, ApplyPatchFileChange::Delete { content }); + changes.insert( + path.into_path_buf(), + ApplyPatchFileChange::Delete { content }, + ); } Hunk::UpdateFile { move_path, chunks, .. @@ -187,17 +192,17 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp let ApplyPatchFileUpdate { unified_diff, content: contents, - } = match unified_diff_from_chunks(&path, &chunks) { + } = match unified_diff_from_chunks(&path, &chunks, fs).await { Ok(diff) => diff, Err(e) => { return MaybeApplyPatchVerified::CorrectnessError(e); } }; changes.insert( - path, + path.into_path_buf(), ApplyPatchFileChange::Update { unified_diff, - move_path: move_path.map(|p| effective_cwd.join(p)), + move_path: move_path.map(|p| effective_cwd.join(p).into_path_buf()), new_content: contents, }, ); @@ -371,7 +376,10 @@ fn extract_apply_patch_from_bash( #[cfg(test)] mod tests { use super::*; + use crate::unified_diff_from_chunks; use assert_matches::assert_matches; + use codex_exec_server::LOCAL_FS; + use codex_utils_absolute_path::test_support::PathExt; use pretty_assertions::assert_eq; use std::fs; use std::path::PathBuf; @@ -450,30 +458,40 @@ mod tests { ); } - #[test] - fn test_implicit_patch_single_arg_is_error() { + #[tokio::test] + async fn test_implicit_patch_single_arg_is_error() { let patch = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch".to_string(); let args = vec![patch]; let dir = tempdir().unwrap(); assert_matches!( - maybe_parse_apply_patch_verified(&args, dir.path()), + maybe_parse_apply_patch_verified( + &args, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + LOCAL_FS.as_ref() + ) + .await, MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation) ); } - #[test] - fn test_implicit_patch_bash_script_is_error() { + #[tokio::test] + async fn test_implicit_patch_bash_script_is_error() { let script = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch"; let args = args_bash(script); let dir = tempdir().unwrap(); assert_matches!( - maybe_parse_apply_patch_verified(&args, dir.path()), + maybe_parse_apply_patch_verified( + &args, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + LOCAL_FS.as_ref() + ) + .await, MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation) ); } - #[test] - fn test_literal() { + #[tokio::test] + async fn test_literal() { let args = strs_to_strings(&[ "apply_patch", r#"*** Begin Patch @@ -497,8 +515,8 @@ mod tests { } } - #[test] - fn test_literal_applypatch() { + #[tokio::test] + async fn test_literal_applypatch() { let args = strs_to_strings(&[ "applypatch", r#"*** Begin Patch @@ -522,20 +540,20 @@ mod tests { } } - #[test] - fn test_heredoc() { - assert_match(&heredoc_script(""), None); + #[tokio::test] + async fn test_heredoc() { + assert_match(&heredoc_script(""), /*expected_workdir*/ None); } - #[test] - fn test_heredoc_non_login_shell() { + #[tokio::test] + async fn test_heredoc_non_login_shell() { let script = heredoc_script(""); let args = strs_to_strings(&["bash", "-c", &script]); - assert_match_args(args, None); + assert_match_args(args, /*expected_workdir*/ None); } - #[test] - fn test_heredoc_applypatch() { + #[tokio::test] + async fn test_heredoc_applypatch() { let args = strs_to_strings(&[ "bash", "-lc", @@ -562,93 +580,96 @@ PATCH"#, } } - #[test] - fn test_powershell_heredoc() { + #[tokio::test] + async fn test_powershell_heredoc() { let script = heredoc_script(""); - assert_match_args(args_powershell(&script), None); + assert_match_args(args_powershell(&script), /*expected_workdir*/ None); } - #[test] - fn test_powershell_heredoc_no_profile() { + #[tokio::test] + async fn test_powershell_heredoc_no_profile() { let script = heredoc_script(""); - assert_match_args(args_powershell_no_profile(&script), None); + assert_match_args( + args_powershell_no_profile(&script), + /*expected_workdir*/ None, + ); } - #[test] - fn test_pwsh_heredoc() { + #[tokio::test] + async fn test_pwsh_heredoc() { let script = heredoc_script(""); - assert_match_args(args_pwsh(&script), None); + assert_match_args(args_pwsh(&script), /*expected_workdir*/ None); } - #[test] - fn test_cmd_heredoc_with_cd() { + #[tokio::test] + async fn test_cmd_heredoc_with_cd() { let script = heredoc_script("cd foo && "); assert_match_args(args_cmd(&script), Some("foo")); } - #[test] - fn test_heredoc_with_leading_cd() { + #[tokio::test] + async fn test_heredoc_with_leading_cd() { assert_match(&heredoc_script("cd foo && "), Some("foo")); } - #[test] - fn test_cd_with_semicolon_is_ignored() { + #[tokio::test] + async fn test_cd_with_semicolon_is_ignored() { assert_not_match(&heredoc_script("cd foo; ")); } - #[test] - fn test_cd_or_apply_patch_is_ignored() { + #[tokio::test] + async fn test_cd_or_apply_patch_is_ignored() { assert_not_match(&heredoc_script("cd bar || ")); } - #[test] - fn test_cd_pipe_apply_patch_is_ignored() { + #[tokio::test] + async fn test_cd_pipe_apply_patch_is_ignored() { assert_not_match(&heredoc_script("cd bar | ")); } - #[test] - fn test_cd_single_quoted_path_with_spaces() { + #[tokio::test] + async fn test_cd_single_quoted_path_with_spaces() { assert_match(&heredoc_script("cd 'foo bar' && "), Some("foo bar")); } - #[test] - fn test_cd_double_quoted_path_with_spaces() { + #[tokio::test] + async fn test_cd_double_quoted_path_with_spaces() { assert_match(&heredoc_script("cd \"foo bar\" && "), Some("foo bar")); } - #[test] - fn test_echo_and_apply_patch_is_ignored() { + #[tokio::test] + async fn test_echo_and_apply_patch_is_ignored() { assert_not_match(&heredoc_script("echo foo && ")); } - #[test] - fn test_apply_patch_with_arg_is_ignored() { + #[tokio::test] + async fn test_apply_patch_with_arg_is_ignored() { let script = "apply_patch foo <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH"; assert_not_match(script); } - #[test] - fn test_double_cd_then_apply_patch_is_ignored() { + #[tokio::test] + async fn test_double_cd_then_apply_patch_is_ignored() { assert_not_match(&heredoc_script("cd foo && cd bar && ")); } - #[test] - fn test_cd_two_args_is_ignored() { + #[tokio::test] + async fn test_cd_two_args_is_ignored() { assert_not_match(&heredoc_script("cd foo bar && ")); } - #[test] - fn test_cd_then_apply_patch_then_extra_is_ignored() { + #[tokio::test] + async fn test_cd_then_apply_patch_then_extra_is_ignored() { let script = heredoc_script_ps("cd bar && ", " && echo done"); assert_not_match(&script); } - #[test] - fn test_echo_then_cd_and_apply_patch_is_ignored() { + #[tokio::test] + async fn test_echo_then_cd_and_apply_patch_is_ignored() { // Ensure preceding commands before the `cd && apply_patch <<...` sequence do not match. assert_not_match(&heredoc_script("echo foo; cd bar && ")); } - #[test] - fn test_unified_diff_last_line_replacement() { + #[tokio::test] + async fn test_unified_diff_last_line_replacement() { // Replace the very last line of the file. let dir = tempdir().unwrap(); let path = dir.path().join("last.txt"); @@ -671,7 +692,10 @@ PATCH"#, _ => panic!("Expected a single UpdateFile hunk"), }; - let diff = unified_diff_from_chunks(&path, chunks).unwrap(); + let path_abs = path.as_path().abs(); + let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref()) + .await + .unwrap(); let expected_diff = r#"@@ -2,2 +2,2 @@ bar -baz @@ -684,8 +708,8 @@ PATCH"#, assert_eq!(expected, diff); } - #[test] - fn test_unified_diff_insert_at_eof() { + #[tokio::test] + async fn test_unified_diff_insert_at_eof() { // Insert a new line at end‑of‑file. let dir = tempdir().unwrap(); let path = dir.path().join("insert.txt"); @@ -706,7 +730,10 @@ PATCH"#, _ => panic!("Expected a single UpdateFile hunk"), }; - let diff = unified_diff_from_chunks(&path, chunks).unwrap(); + let path_abs = path.as_path().abs(); + let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref()) + .await + .unwrap(); let expected_diff = r#"@@ -3 +3,2 @@ baz +quux @@ -718,8 +745,8 @@ PATCH"#, assert_eq!(expected, diff); } - #[test] - fn test_apply_patch_should_resolve_absolute_paths_in_cwd() { + #[tokio::test] + async fn test_apply_patch_should_resolve_absolute_paths_in_cwd() { let session_dir = tempdir().unwrap(); let relative_path = "source.txt"; @@ -739,7 +766,12 @@ PATCH"#, .to_string(), ]; - let result = maybe_parse_apply_patch_verified(&argv, session_dir.path()); + let result = maybe_parse_apply_patch_verified( + &argv, + &AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(), + LOCAL_FS.as_ref(), + ) + .await; // Verify the patch contents - as otherwise we may have pulled contents // from the wrong file (as we're using relative paths) @@ -759,13 +791,13 @@ PATCH"#, }, )]), patch: argv[1].clone(), - cwd: session_dir.path().to_path_buf(), + cwd: AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(), }) ); } - #[test] - fn test_apply_patch_resolves_move_path_with_effective_cwd() { + #[tokio::test] + async fn test_apply_patch_resolves_move_path_with_effective_cwd() { let session_dir = tempdir().unwrap(); let worktree_rel = "alt"; let worktree_dir = session_dir.path().join(worktree_rel); @@ -787,13 +819,18 @@ PATCH"#, let shell_script = format!("cd {worktree_rel} && apply_patch <<'PATCH'\n{patch}\nPATCH"); let argv = vec!["bash".into(), "-lc".into(), shell_script]; - let result = maybe_parse_apply_patch_verified(&argv, session_dir.path()); + let result = maybe_parse_apply_patch_verified( + &argv, + &AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(), + LOCAL_FS.as_ref(), + ) + .await; let action = match result { MaybeApplyPatchVerified::Body(action) => action, other => panic!("expected verified body, got {other:?}"), }; - assert_eq!(action.cwd, worktree_dir); + assert_eq!(action.cwd.as_path(), worktree_dir.as_path()); let change = action .changes() diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index 3041c4b2cd..6b6aba2df3 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -4,11 +4,16 @@ mod seek_sequence; mod standalone_executable; use std::collections::HashMap; +use std::io; use std::path::Path; use std::path::PathBuf; use anyhow::Context; use anyhow::Result; +use codex_exec_server::CreateDirectoryOptions; +use codex_exec_server::ExecutorFileSystem; +use codex_exec_server::RemoveOptions; +use codex_utils_absolute_path::AbsolutePathBuf; pub use parser::Hunk; pub use parser::ParseError; use parser::ParseError::*; @@ -134,7 +139,7 @@ pub struct ApplyPatchAction { pub patch: String, /// The working directory that was used to resolve relative paths in the patch. - pub cwd: PathBuf, + pub cwd: AbsolutePathBuf, } impl ApplyPatchAction { @@ -149,11 +154,7 @@ impl ApplyPatchAction { /// Should be used exclusively for testing. (Not worth the overhead of /// creating a feature flag for this.) - pub fn new_add_for_test(path: &Path, content: String) -> Self { - if !path.is_absolute() { - panic!("path must be absolute"); - } - + pub fn new_add_for_test(path: &AbsolutePathBuf, content: String) -> Self { #[expect(clippy::expect_used)] let filename = path .file_name() @@ -170,20 +171,19 @@ impl ApplyPatchAction { #[expect(clippy::expect_used)] Self { changes, - cwd: path - .parent() - .expect("path should have parent") - .to_path_buf(), + cwd: path.parent().expect("path should have parent"), patch, } } } /// Applies the patch and prints the result to stdout/stderr. -pub fn apply_patch( +pub async fn apply_patch( patch: &str, + cwd: &AbsolutePathBuf, stdout: &mut impl std::io::Write, stderr: &mut impl std::io::Write, + fs: &dyn ExecutorFileSystem, ) -> Result<(), ApplyPatchError> { let hunks = match parse_patch(patch) { Ok(source) => source.hunks, @@ -207,45 +207,21 @@ pub fn apply_patch( } }; - apply_hunks(&hunks, stdout, stderr)?; + apply_hunks(&hunks, cwd, stdout, stderr, fs).await?; Ok(()) } /// Applies hunks and continues to update stdout/stderr -pub fn apply_hunks( +pub async fn apply_hunks( hunks: &[Hunk], + cwd: &AbsolutePathBuf, stdout: &mut impl std::io::Write, stderr: &mut impl std::io::Write, + fs: &dyn ExecutorFileSystem, ) -> Result<(), ApplyPatchError> { - let _existing_paths: Vec<&Path> = hunks - .iter() - .filter_map(|hunk| match hunk { - Hunk::AddFile { .. } => { - // The file is being added, so it doesn't exist yet. - None - } - Hunk::DeleteFile { path } => Some(path.as_path()), - Hunk::UpdateFile { - path, move_path, .. - } => match move_path { - Some(move_path) => { - if std::fs::metadata(move_path) - .map(|m| m.is_file()) - .unwrap_or(false) - { - Some(move_path.as_path()) - } else { - None - } - } - None => Some(path.as_path()), - }, - }) - .collect::>(); - // Delegate to a helper that applies each hunk to the filesystem. - match apply_hunks_to_files(hunks) { + match apply_hunks_to_files(hunks, cwd, fs).await { Ok(affected) => { print_summary(&affected, stdout).map_err(ApplyPatchError::from)?; Ok(()) @@ -267,7 +243,8 @@ pub fn apply_hunks( /// Applies each parsed patch hunk to the filesystem. /// Returns an error if any of the changes could not be applied. -/// Tracks file paths affected by applying a patch. +/// Tracks file paths affected by applying a patch, preserving the path spelling +/// from the patch for user-facing summaries. pub struct AffectedPaths { pub added: Vec, pub modified: Vec, @@ -276,7 +253,11 @@ pub struct AffectedPaths { /// Apply the hunks to the filesystem, returning which files were added, modified, or deleted. /// Returns an error if the patch could not be applied. -fn apply_hunks_to_files(hunks: &[Hunk]) -> anyhow::Result { +async fn apply_hunks_to_files( + hunks: &[Hunk], + cwd: &AbsolutePathBuf, + fs: &dyn ExecutorFileSystem, +) -> anyhow::Result { if hunks.is_empty() { anyhow::bail!("No files were modified."); } @@ -285,48 +266,97 @@ fn apply_hunks_to_files(hunks: &[Hunk]) -> anyhow::Result { let mut modified: Vec = Vec::new(); let mut deleted: Vec = Vec::new(); for hunk in hunks { + let affected_path = hunk.path().to_path_buf(); + let path_abs = hunk.resolve_path(cwd); match hunk { - Hunk::AddFile { path, contents } => { - if let Some(parent) = path.parent() - && !parent.as_os_str().is_empty() - { - std::fs::create_dir_all(parent).with_context(|| { - format!("Failed to create parent directories for {}", path.display()) - })?; + Hunk::AddFile { contents, .. } => { + if let Some(parent_abs) = path_abs.parent() { + fs.create_directory(&parent_abs, CreateDirectoryOptions { recursive: true }) + .await + .with_context(|| { + format!( + "Failed to create parent directories for {}", + path_abs.display() + ) + })?; } - std::fs::write(path, contents) - .with_context(|| format!("Failed to write file {}", path.display()))?; - added.push(path.clone()); + fs.write_file(&path_abs, contents.clone().into_bytes()) + .await + .with_context(|| format!("Failed to write file {}", path_abs.display()))?; + added.push(affected_path); } - Hunk::DeleteFile { path } => { - std::fs::remove_file(path) - .with_context(|| format!("Failed to delete file {}", path.display()))?; - deleted.push(path.clone()); + Hunk::DeleteFile { .. } => { + let result: io::Result<()> = async { + let metadata = fs.get_metadata(&path_abs).await?; + if metadata.is_directory { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "path is a directory", + )); + } + fs.remove( + &path_abs, + RemoveOptions { + recursive: false, + force: false, + }, + ) + .await + } + .await; + result.with_context(|| format!("Failed to delete file {}", path_abs.display()))?; + deleted.push(affected_path); } Hunk::UpdateFile { - path, - move_path, - chunks, + move_path, chunks, .. } => { let AppliedPatch { new_contents, .. } = - derive_new_contents_from_chunks(path, chunks)?; + derive_new_contents_from_chunks(&path_abs, chunks, fs).await?; if let Some(dest) = move_path { - if let Some(parent) = dest.parent() - && !parent.as_os_str().is_empty() - { - std::fs::create_dir_all(parent).with_context(|| { - format!("Failed to create parent directories for {}", dest.display()) + let dest_abs = AbsolutePathBuf::resolve_path_against_base(dest, cwd); + if let Some(parent_abs) = dest_abs.parent() { + fs.create_directory( + &parent_abs, + CreateDirectoryOptions { recursive: true }, + ) + .await + .with_context(|| { + format!( + "Failed to create parent directories for {}", + dest_abs.display() + ) })?; } - std::fs::write(dest, new_contents) - .with_context(|| format!("Failed to write file {}", dest.display()))?; - std::fs::remove_file(path) - .with_context(|| format!("Failed to remove original {}", path.display()))?; - modified.push(dest.clone()); + fs.write_file(&dest_abs, new_contents.into_bytes()) + .await + .with_context(|| format!("Failed to write file {}", dest_abs.display()))?; + let result: io::Result<()> = async { + let metadata = fs.get_metadata(&path_abs).await?; + if metadata.is_directory { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "path is a directory", + )); + } + fs.remove( + &path_abs, + RemoveOptions { + recursive: false, + force: false, + }, + ) + .await + } + .await; + result.with_context(|| { + format!("Failed to remove original {}", path_abs.display()) + })?; + modified.push(affected_path); } else { - std::fs::write(path, new_contents) - .with_context(|| format!("Failed to write file {}", path.display()))?; - modified.push(path.clone()); + fs.write_file(&path_abs, new_contents.into_bytes()) + .await + .with_context(|| format!("Failed to write file {}", path_abs.display()))?; + modified.push(affected_path); } } } @@ -345,19 +375,17 @@ struct AppliedPatch { /// Return *only* the new file contents (joined into a single `String`) after /// applying the chunks to the file at `path`. -fn derive_new_contents_from_chunks( - path: &Path, +async fn derive_new_contents_from_chunks( + path_abs: &AbsolutePathBuf, chunks: &[UpdateFileChunk], + fs: &dyn ExecutorFileSystem, ) -> std::result::Result { - let original_contents = match std::fs::read_to_string(path) { - Ok(contents) => contents, - Err(err) => { - return Err(ApplyPatchError::IoError(IoError { - context: format!("Failed to read file to update {}", path.display()), - source: err, - })); - } - }; + let original_contents = fs.read_file_text(path_abs).await.map_err(|err| { + ApplyPatchError::IoError(IoError { + context: format!("Failed to read file to update {}", path_abs.display()), + source: err, + }) + })?; let mut original_lines: Vec = original_contents.split('\n').map(String::from).collect(); @@ -367,7 +395,7 @@ fn derive_new_contents_from_chunks( original_lines.pop(); } - let replacements = compute_replacements(&original_lines, path, chunks)?; + let replacements = compute_replacements(&original_lines, path_abs.as_path(), chunks)?; let new_lines = apply_replacements(original_lines, &replacements); let mut new_lines = new_lines; if !new_lines.last().is_some_and(String::is_empty) { @@ -508,22 +536,24 @@ pub struct ApplyPatchFileUpdate { content: String, } -pub fn unified_diff_from_chunks( - path: &Path, +pub async fn unified_diff_from_chunks( + path_abs: &AbsolutePathBuf, chunks: &[UpdateFileChunk], + fs: &dyn ExecutorFileSystem, ) -> std::result::Result { - unified_diff_from_chunks_with_context(path, chunks, /*context*/ 1) + unified_diff_from_chunks_with_context(path_abs, chunks, /*context*/ 1, fs).await } -pub fn unified_diff_from_chunks_with_context( - path: &Path, +pub async fn unified_diff_from_chunks_with_context( + path_abs: &AbsolutePathBuf, chunks: &[UpdateFileChunk], context: usize, + fs: &dyn ExecutorFileSystem, ) -> std::result::Result { let AppliedPatch { original_contents, new_contents, - } = derive_new_contents_from_chunks(path, chunks)?; + } = derive_new_contents_from_chunks(path_abs, chunks, fs).await?; let text_diff = TextDiff::from_lines(&original_contents, &new_contents); let unified_diff = text_diff.unified_diff().context_radius(context).to_string(); Ok(ApplyPatchFileUpdate { @@ -554,6 +584,8 @@ pub fn print_summary( #[cfg(test)] mod tests { use super::*; + use codex_exec_server::LOCAL_FS; + use codex_utils_absolute_path::test_support::PathExt; use pretty_assertions::assert_eq; use std::fs; use std::string::ToString; @@ -564,8 +596,8 @@ mod tests { format!("*** Begin Patch\n{body}\n*** End Patch") } - #[test] - fn test_add_file_hunk_creates_file_with_contents() { + #[tokio::test] + async fn test_add_file_hunk_creates_file_with_contents() { let dir = tempdir().unwrap(); let path = dir.path().join("add.txt"); let patch = wrap_patch(&format!( @@ -576,7 +608,15 @@ mod tests { )); let mut stdout = Vec::new(); let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + apply_patch( + &patch, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + ) + .await + .unwrap(); // Verify expected stdout and stderr outputs. let stdout_str = String::from_utf8(stdout).unwrap(); let stderr_str = String::from_utf8(stderr).unwrap(); @@ -590,15 +630,88 @@ mod tests { assert_eq!(contents, "ab\ncd\n"); } - #[test] - fn test_delete_file_hunk_removes_file() { + #[tokio::test] + async fn test_apply_patch_hunks_accept_relative_and_absolute_paths() { + let dir = tempdir().unwrap(); + let cwd = dir.path().abs(); + let relative_add = dir.path().join("relative-add.txt"); + let absolute_add = dir.path().join("absolute-add.txt"); + let relative_delete = dir.path().join("relative-delete.txt"); + let absolute_delete = dir.path().join("absolute-delete.txt"); + let relative_update = dir.path().join("relative-update.txt"); + let absolute_update = dir.path().join("absolute-update.txt"); + fs::write(&relative_delete, "delete relative\n").unwrap(); + fs::write(&absolute_delete, "delete absolute\n").unwrap(); + fs::write(&relative_update, "relative old\n").unwrap(); + fs::write(&absolute_update, "absolute old\n").unwrap(); + + let patch = wrap_patch(&format!( + r#"*** Add File: relative-add.txt ++relative add +*** Add File: {} ++absolute add +*** Delete File: relative-delete.txt +*** Delete File: {} +*** Update File: relative-update.txt +@@ +-relative old ++relative new +*** Update File: {} +@@ +-absolute old ++absolute new"#, + absolute_add.display(), + absolute_delete.display(), + absolute_update.display(), + )); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + apply_patch(&patch, &cwd, &mut stdout, &mut stderr, LOCAL_FS.as_ref()) + .await + .unwrap(); + + assert_eq!(fs::read_to_string(&relative_add).unwrap(), "relative add\n"); + assert_eq!(fs::read_to_string(&absolute_add).unwrap(), "absolute add\n"); + assert!(!relative_delete.exists()); + assert!(!absolute_delete.exists()); + assert_eq!( + fs::read_to_string(&relative_update).unwrap(), + "relative new\n" + ); + assert_eq!( + fs::read_to_string(&absolute_update).unwrap(), + "absolute new\n" + ); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + assert_eq!( + String::from_utf8(stdout).unwrap(), + format!( + "Success. Updated the following files:\nA relative-add.txt\nA {}\nM relative-update.txt\nM {}\nD relative-delete.txt\nD {}\n", + absolute_add.display(), + absolute_update.display(), + absolute_delete.display(), + ) + ); + } + + #[tokio::test] + async fn test_delete_file_hunk_removes_file() { let dir = tempdir().unwrap(); let path = dir.path().join("del.txt"); fs::write(&path, "x").unwrap(); let patch = wrap_patch(&format!("*** Delete File: {}", path.display())); let mut stdout = Vec::new(); let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + apply_patch( + &patch, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + ) + .await + .unwrap(); let stdout_str = String::from_utf8(stdout).unwrap(); let stderr_str = String::from_utf8(stderr).unwrap(); let expected_out = format!( @@ -610,8 +723,8 @@ mod tests { assert!(!path.exists()); } - #[test] - fn test_update_file_hunk_modifies_content() { + #[tokio::test] + async fn test_update_file_hunk_modifies_content() { let dir = tempdir().unwrap(); let path = dir.path().join("update.txt"); fs::write(&path, "foo\nbar\n").unwrap(); @@ -625,7 +738,15 @@ mod tests { )); let mut stdout = Vec::new(); let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + apply_patch( + &patch, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + ) + .await + .unwrap(); // Validate modified file contents and expected stdout/stderr. let stdout_str = String::from_utf8(stdout).unwrap(); let stderr_str = String::from_utf8(stderr).unwrap(); @@ -639,8 +760,8 @@ mod tests { assert_eq!(contents, "foo\nbaz\n"); } - #[test] - fn test_update_file_hunk_can_move_file() { + #[tokio::test] + async fn test_update_file_hunk_can_move_file() { let dir = tempdir().unwrap(); let src = dir.path().join("src.txt"); let dest = dir.path().join("dst.txt"); @@ -656,7 +777,15 @@ mod tests { )); let mut stdout = Vec::new(); let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + apply_patch( + &patch, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + ) + .await + .unwrap(); // Validate move semantics and expected stdout/stderr. let stdout_str = String::from_utf8(stdout).unwrap(); let stderr_str = String::from_utf8(stderr).unwrap(); @@ -673,8 +802,8 @@ mod tests { /// Verify that a single `Update File` hunk with multiple change chunks can update different /// parts of a file and that the file is listed only once in the summary. - #[test] - fn test_multiple_update_chunks_apply_to_single_file() { + #[tokio::test] + async fn test_multiple_update_chunks_apply_to_single_file() { // Start with a file containing four lines. let dir = tempdir().unwrap(); let path = dir.path().join("multi.txt"); @@ -696,7 +825,15 @@ mod tests { )); let mut stdout = Vec::new(); let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + apply_patch( + &patch, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + ) + .await + .unwrap(); let stdout_str = String::from_utf8(stdout).unwrap(); let stderr_str = String::from_utf8(stderr).unwrap(); let expected_out = format!( @@ -713,8 +850,8 @@ mod tests { /// replacements in separate chunks that appear in non‑adjacent parts of the /// file. Verifies that all edits are applied and that the summary lists the /// file only once. - #[test] - fn test_update_file_hunk_interleaved_changes() { + #[tokio::test] + async fn test_update_file_hunk_interleaved_changes() { let dir = tempdir().unwrap(); let path = dir.path().join("interleaved.txt"); @@ -745,7 +882,15 @@ mod tests { let mut stdout = Vec::new(); let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + apply_patch( + &patch, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + ) + .await + .unwrap(); let stdout_str = String::from_utf8(stdout).unwrap(); let stderr_str = String::from_utf8(stderr).unwrap(); @@ -761,8 +906,8 @@ mod tests { assert_eq!(contents, "a\nB\nc\nd\nE\nf\ng\n"); } - #[test] - fn test_pure_addition_chunk_followed_by_removal() { + #[tokio::test] + async fn test_pure_addition_chunk_followed_by_removal() { let dir = tempdir().unwrap(); let path = dir.path().join("panic.txt"); fs::write(&path, "line1\nline2\nline3\n").unwrap(); @@ -780,7 +925,15 @@ mod tests { )); let mut stdout = Vec::new(); let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + apply_patch( + &patch, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + ) + .await + .unwrap(); let contents = fs::read_to_string(path).unwrap(); assert_eq!( contents, @@ -794,8 +947,8 @@ mod tests { /// internal matcher failed requiring an exact byte-for-byte match. The /// fuzzy-matching pass that normalises common punctuation should now bridge /// the gap. - #[test] - fn test_update_line_with_unicode_dash() { + #[tokio::test] + async fn test_update_line_with_unicode_dash() { let dir = tempdir().unwrap(); let path = dir.path().join("unicode.py"); @@ -814,7 +967,15 @@ mod tests { let mut stdout = Vec::new(); let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + apply_patch( + &patch, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + ) + .await + .unwrap(); // File should now contain the replaced comment. let expected = "import asyncio # HELLO\n"; @@ -833,8 +994,8 @@ mod tests { assert_eq!(String::from_utf8(stderr).unwrap(), ""); } - #[test] - fn test_unified_diff() { + #[tokio::test] + async fn test_unified_diff() { // Start with a file containing four lines. let dir = tempdir().unwrap(); let path = dir.path().join("multi.txt"); @@ -857,7 +1018,10 @@ mod tests { [Hunk::UpdateFile { chunks, .. }] => chunks, _ => panic!("Expected a single UpdateFile hunk"), }; - let diff = unified_diff_from_chunks(&path, update_file_chunks).unwrap(); + let path_abs = path.as_path().abs(); + let diff = unified_diff_from_chunks(&path_abs, update_file_chunks, LOCAL_FS.as_ref()) + .await + .unwrap(); let expected_diff = r#"@@ -1,4 +1,4 @@ foo -bar @@ -873,8 +1037,8 @@ mod tests { assert_eq!(expected, diff); } - #[test] - fn test_unified_diff_first_line_replacement() { + #[tokio::test] + async fn test_unified_diff_first_line_replacement() { // Replace the very first line of the file. let dir = tempdir().unwrap(); let path = dir.path().join("first.txt"); @@ -896,7 +1060,10 @@ mod tests { _ => panic!("Expected a single UpdateFile hunk"), }; - let diff = unified_diff_from_chunks(&path, chunks).unwrap(); + let path_abs = path.as_path().abs(); + let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref()) + .await + .unwrap(); let expected_diff = r#"@@ -1,2 +1,2 @@ -foo +FOO @@ -909,8 +1076,8 @@ mod tests { assert_eq!(expected, diff); } - #[test] - fn test_unified_diff_last_line_replacement() { + #[tokio::test] + async fn test_unified_diff_last_line_replacement() { // Replace the very last line of the file. let dir = tempdir().unwrap(); let path = dir.path().join("last.txt"); @@ -933,7 +1100,10 @@ mod tests { _ => panic!("Expected a single UpdateFile hunk"), }; - let diff = unified_diff_from_chunks(&path, chunks).unwrap(); + let path_abs = path.as_path().abs(); + let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref()) + .await + .unwrap(); let expected_diff = r#"@@ -2,2 +2,2 @@ bar -baz @@ -946,8 +1116,8 @@ mod tests { assert_eq!(expected, diff); } - #[test] - fn test_unified_diff_insert_at_eof() { + #[tokio::test] + async fn test_unified_diff_insert_at_eof() { // Insert a new line at end‑of‑file. let dir = tempdir().unwrap(); let path = dir.path().join("insert.txt"); @@ -968,7 +1138,10 @@ mod tests { _ => panic!("Expected a single UpdateFile hunk"), }; - let diff = unified_diff_from_chunks(&path, chunks).unwrap(); + let path_abs = path.as_path().abs(); + let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref()) + .await + .unwrap(); let expected_diff = r#"@@ -3 +3,2 @@ baz +quux @@ -980,8 +1153,8 @@ mod tests { assert_eq!(expected, diff); } - #[test] - fn test_unified_diff_interleaved_changes() { + #[tokio::test] + async fn test_unified_diff_interleaved_changes() { // Original file with six lines. let dir = tempdir().unwrap(); let path = dir.path().join("interleaved.txt"); @@ -1014,7 +1187,10 @@ mod tests { _ => panic!("Expected a single UpdateFile hunk"), }; - let diff = unified_diff_from_chunks(&path, chunks).unwrap(); + let path_abs = path.as_path().abs(); + let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref()) + .await + .unwrap(); let expected_diff = r#"@@ -1,6 +1,7 @@ a @@ -1037,7 +1213,15 @@ mod tests { let mut stdout = Vec::new(); let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + apply_patch( + &patch, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + ) + .await + .unwrap(); let contents = fs::read_to_string(path).unwrap(); assert_eq!( contents, @@ -1052,8 +1236,8 @@ g ); } - #[test] - fn test_apply_patch_fails_on_write_error() { + #[tokio::test] + async fn test_apply_patch_fails_on_write_error() { let dir = tempdir().unwrap(); let path = dir.path().join("readonly.txt"); fs::write(&path, "before\n").unwrap(); @@ -1068,7 +1252,14 @@ g let mut stdout = Vec::new(); let mut stderr = Vec::new(); - let result = apply_patch(&patch, &mut stdout, &mut stderr); + let result = apply_patch( + &patch, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + ) + .await; assert!(result.is_err()); } } diff --git a/codex-rs/apply-patch/src/parser.rs b/codex-rs/apply-patch/src/parser.rs index 8785b38519..24ebcb1463 100644 --- a/codex-rs/apply-patch/src/parser.rs +++ b/codex-rs/apply-patch/src/parser.rs @@ -23,6 +23,9 @@ //! The parser below is a little more lenient than the explicit spec and allows for //! leading/trailing whitespace around patch markers. use crate::ApplyPatchArgs; +use codex_utils_absolute_path::AbsolutePathBuf; +#[cfg(test)] +use codex_utils_absolute_path::test_support::PathBufExt; use std::path::Path; use std::path::PathBuf; @@ -76,11 +79,28 @@ pub enum Hunk { } impl Hunk { - pub fn resolve_path(&self, cwd: &Path) -> PathBuf { + pub fn resolve_path(&self, cwd: &AbsolutePathBuf) -> AbsolutePathBuf { + let path = match self { + Hunk::UpdateFile { path, .. } => path, + Hunk::AddFile { .. } | Hunk::DeleteFile { .. } => self.path(), + }; + AbsolutePathBuf::resolve_path_against_base(path, cwd) + } + + /// Returns the path affected by this hunk, using the move destination for rename hunks. + pub fn path(&self) -> &Path { match self { - Hunk::AddFile { path, .. } => cwd.join(path), - Hunk::DeleteFile { path } => cwd.join(path), - Hunk::UpdateFile { path, .. } => cwd.join(path), + Hunk::AddFile { path, .. } => path, + Hunk::DeleteFile { path } => path, + Hunk::UpdateFile { + move_path: Some(path), + .. + } => path, + Hunk::UpdateFile { + path, + move_path: None, + .. + } => path, } } } @@ -583,6 +603,108 @@ fn test_parse_patch() { ); } +#[test] +fn test_parse_patch_accepts_relative_and_absolute_hunk_paths() { + let dir = tempfile::tempdir().unwrap(); + let absolute_delete = dir.path().join("absolute-delete.py").abs(); + let absolute_update = dir.path().join("absolute-update.py").abs(); + let patch_text = format!( + r#"*** Begin Patch +*** Add File: relative-add.py ++content +*** Delete File: {} +*** Update File: {} +@@ +-old ++new +*** End Patch"#, + absolute_delete.display(), + absolute_update.display() + ); + + assert_eq!( + parse_patch_text(&patch_text, ParseMode::Strict) + .unwrap() + .hunks, + vec![ + AddFile { + path: PathBuf::from("relative-add.py"), + contents: "content\n".to_string() + }, + DeleteFile { + path: absolute_delete.to_path_buf() + }, + UpdateFile { + path: absolute_update.to_path_buf(), + move_path: None, + chunks: vec![UpdateFileChunk { + change_context: None, + old_lines: vec!["old".to_string()], + new_lines: vec!["new".to_string()], + is_end_of_file: false + }] + }, + ] + ); +} + +#[test] +fn test_hunk_resolve_path_accepts_relative_and_absolute_paths() { + let cwd_dir = tempfile::tempdir().unwrap(); + let cwd = cwd_dir.path().to_path_buf().abs(); + let absolute_dir = tempfile::tempdir().unwrap(); + let absolute_add = absolute_dir.path().join("absolute-add.py").abs(); + let absolute_delete = absolute_dir.path().join("absolute-delete.py").abs(); + let absolute_update = absolute_dir.path().join("absolute-update.py").abs(); + + for (hunk, expected_path) in [ + ( + AddFile { + path: PathBuf::from("relative-add.py"), + contents: String::new(), + }, + cwd.join("relative-add.py"), + ), + ( + DeleteFile { + path: PathBuf::from("relative-delete.py"), + }, + cwd.join("relative-delete.py"), + ), + ( + UpdateFile { + path: PathBuf::from("relative-update.py"), + move_path: None, + chunks: Vec::new(), + }, + cwd.join("relative-update.py"), + ), + ( + AddFile { + path: absolute_add.to_path_buf(), + contents: String::new(), + }, + absolute_add, + ), + ( + DeleteFile { + path: absolute_delete.to_path_buf(), + }, + absolute_delete, + ), + ( + UpdateFile { + path: absolute_update.to_path_buf(), + move_path: None, + chunks: Vec::new(), + }, + absolute_update, + ), + ] { + assert_eq!(hunk.resolve_path(&cwd), expected_path); + } +} + #[test] fn test_parse_patch_lenient() { let patch_text = r#"*** Begin Patch @@ -672,7 +794,7 @@ fn test_parse_patch_lenient() { #[test] fn test_parse_one_hunk() { assert_eq!( - parse_one_hunk(&["bad"], 234), + parse_one_hunk(&["bad"], /*line_number*/ 234), Err(InvalidHunkError { message: "'bad' is not a valid hunk header. \ Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'".to_string(), @@ -685,7 +807,11 @@ fn test_parse_one_hunk() { #[test] fn test_update_file_chunk() { assert_eq!( - parse_update_file_chunk(&["bad"], 123, false), + parse_update_file_chunk( + &["bad"], + /*line_number*/ 123, + /*allow_missing_context*/ false + ), Err(InvalidHunkError { message: "Expected update hunk to start with a @@ context marker, got: 'bad'" .to_string(), @@ -693,14 +819,18 @@ fn test_update_file_chunk() { }) ); assert_eq!( - parse_update_file_chunk(&["@@"], 123, false), + parse_update_file_chunk( + &["@@"], + /*line_number*/ 123, + /*allow_missing_context*/ false + ), Err(InvalidHunkError { message: "Update hunk does not contain any lines".to_string(), line_number: 124 }) ); assert_eq!( - parse_update_file_chunk(&["@@", "bad"], 123, false), + parse_update_file_chunk(&["@@", "bad"], /*line_number*/ 123, /*allow_missing_context*/ false), Err(InvalidHunkError { message: "Unexpected line found in update hunk: 'bad'. \ Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)".to_string(), @@ -708,7 +838,11 @@ fn test_update_file_chunk() { }) ); assert_eq!( - parse_update_file_chunk(&["@@", "*** End of File"], 123, false), + parse_update_file_chunk( + &["@@", "*** End of File"], + /*line_number*/ 123, + /*allow_missing_context*/ false + ), Err(InvalidHunkError { message: "Update hunk does not contain any lines".to_string(), line_number: 124 @@ -725,8 +859,8 @@ fn test_update_file_chunk() { " context2", "*** End Patch", ], - 123, - false + /*line_number*/ 123, + /*allow_missing_context*/ false ), Ok(( (UpdateFileChunk { @@ -749,7 +883,11 @@ fn test_update_file_chunk() { )) ); assert_eq!( - parse_update_file_chunk(&["@@", "+line", "*** End of File"], 123, false), + parse_update_file_chunk( + &["@@", "+line", "*** End of File"], + /*line_number*/ 123, + /*allow_missing_context*/ false + ), Ok(( (UpdateFileChunk { change_context: None, diff --git a/codex-rs/apply-patch/src/seek_sequence.rs b/codex-rs/apply-patch/src/seek_sequence.rs index b005b08c75..3555963120 100644 --- a/codex-rs/apply-patch/src/seek_sequence.rs +++ b/codex-rs/apply-patch/src/seek_sequence.rs @@ -122,7 +122,10 @@ mod tests { fn test_exact_match_finds_sequence() { let lines = to_vec(&["foo", "bar", "baz"]); let pattern = to_vec(&["bar", "baz"]); - assert_eq!(seek_sequence(&lines, &pattern, 0, false), Some(1)); + assert_eq!( + seek_sequence(&lines, &pattern, /*start*/ 0, /*eof*/ false), + Some(1) + ); } #[test] @@ -130,7 +133,10 @@ mod tests { let lines = to_vec(&["foo ", "bar\t\t"]); // Pattern omits trailing whitespace. let pattern = to_vec(&["foo", "bar"]); - assert_eq!(seek_sequence(&lines, &pattern, 0, false), Some(0)); + assert_eq!( + seek_sequence(&lines, &pattern, /*start*/ 0, /*eof*/ false), + Some(0) + ); } #[test] @@ -138,7 +144,10 @@ mod tests { let lines = to_vec(&[" foo ", " bar\t"]); // Pattern omits any additional whitespace. let pattern = to_vec(&["foo", "bar"]); - assert_eq!(seek_sequence(&lines, &pattern, 0, false), Some(0)); + assert_eq!( + seek_sequence(&lines, &pattern, /*start*/ 0, /*eof*/ false), + Some(0) + ); } #[test] @@ -146,6 +155,9 @@ mod tests { let lines = to_vec(&["just one line"]); let pattern = to_vec(&["too", "many", "lines"]); // Should not panic – must return None when pattern cannot possibly fit. - assert_eq!(seek_sequence(&lines, &pattern, 0, false), None); + assert_eq!( + seek_sequence(&lines, &pattern, /*start*/ 0, /*eof*/ false), + None + ); } } diff --git a/codex-rs/apply-patch/src/standalone_executable.rs b/codex-rs/apply-patch/src/standalone_executable.rs index d77a82fa95..149bfd3382 100644 --- a/codex-rs/apply-patch/src/standalone_executable.rs +++ b/codex-rs/apply-patch/src/standalone_executable.rs @@ -48,7 +48,30 @@ pub fn run_main() -> i32 { let mut stdout = std::io::stdout(); let mut stderr = std::io::stderr(); - match crate::apply_patch(&patch_arg, &mut stdout, &mut stderr) { + let cwd = match codex_utils_absolute_path::AbsolutePathBuf::current_dir() { + Ok(cwd) => cwd, + Err(err) => { + eprintln!("Error: Failed to determine current directory.\n{err}"); + return 1; + } + }; + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(err) => { + eprintln!("Error: Failed to initialize runtime.\n{err}"); + return 1; + } + }; + match runtime.block_on(crate::apply_patch( + &patch_arg, + &cwd, + &mut stdout, + &mut stderr, + codex_exec_server::LOCAL_FS.as_ref(), + )) { Ok(()) => { // Flush to ensure output ordering when used in pipelines. let _ = stdout.flush(); diff --git a/codex-rs/apply-patch/tests/suite/tool.rs b/codex-rs/apply-patch/tests/suite/tool.rs index 56cd6e57a7..8499d0fb90 100644 --- a/codex-rs/apply-patch/tests/suite/tool.rs +++ b/codex-rs/apply-patch/tests/suite/tool.rs @@ -2,6 +2,7 @@ use assert_cmd::Command; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; +use std::path::PathBuf; use tempfile::tempdir; fn run_apply_patch_in_dir(dir: &Path, patch: &str) -> anyhow::Result { @@ -16,9 +17,14 @@ fn apply_patch_command(dir: &Path) -> anyhow::Result { Ok(cmd) } +fn resolved_under(root: &Path, path: &str) -> anyhow::Result { + Ok(root.canonicalize()?.join(path)) +} + #[test] fn test_apply_patch_cli_applies_multiple_operations() -> anyhow::Result<()> { let tmp = tempdir()?; + let add_path = tmp.path().join("nested/new.txt"); let modify_path = tmp.path().join("modify.txt"); let delete_path = tmp.path().join("delete.txt"); @@ -31,10 +37,7 @@ fn test_apply_patch_cli_applies_multiple_operations() -> anyhow::Result<()> { "Success. Updated the following files:\nA nested/new.txt\nM modify.txt\nD delete.txt\n", ); - assert_eq!( - fs::read_to_string(tmp.path().join("nested/new.txt"))?, - "created\n" - ); + assert_eq!(fs::read_to_string(add_path)?, "created\n"); assert_eq!(fs::read_to_string(&modify_path)?, "line1\nchanged\n"); assert!(!delete_path.exists()); @@ -98,13 +101,17 @@ fn test_apply_patch_cli_rejects_empty_patch() -> anyhow::Result<()> { fn test_apply_patch_cli_reports_missing_context() -> anyhow::Result<()> { let tmp = tempdir()?; let target_path = tmp.path().join("modify.txt"); + let expected_target_path = resolved_under(tmp.path(), "modify.txt")?; fs::write(&target_path, "line1\nline2\n")?; apply_patch_command(tmp.path())? .arg("*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch") .assert() .failure() - .stderr("Failed to find expected lines in modify.txt:\nmissing\n"); + .stderr(format!( + "Failed to find expected lines in {}:\nmissing\n", + expected_target_path.display() + )); assert_eq!(fs::read_to_string(&target_path)?, "line1\nline2\n"); Ok(()) @@ -113,12 +120,16 @@ fn test_apply_patch_cli_reports_missing_context() -> anyhow::Result<()> { #[test] fn test_apply_patch_cli_rejects_missing_file_delete() -> anyhow::Result<()> { let tmp = tempdir()?; + let missing_path = resolved_under(tmp.path(), "missing.txt")?; apply_patch_command(tmp.path())? .arg("*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch") .assert() .failure() - .stderr("Failed to delete file missing.txt\n"); + .stderr(format!( + "Failed to delete file {}\n", + missing_path.display() + )); Ok(()) } @@ -139,14 +150,16 @@ fn test_apply_patch_cli_rejects_empty_update_hunk() -> anyhow::Result<()> { #[test] fn test_apply_patch_cli_requires_existing_file_for_update() -> anyhow::Result<()> { let tmp = tempdir()?; + let missing_path = resolved_under(tmp.path(), "missing.txt")?; apply_patch_command(tmp.path())? .arg("*** Begin Patch\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch") .assert() .failure() - .stderr( - "Failed to read file to update missing.txt: No such file or directory (os error 2)\n", - ); + .stderr(format!( + "Failed to read file to update {}: No such file or directory (os error 2)\n", + missing_path.display() + )); Ok(()) } @@ -195,13 +208,18 @@ fn test_apply_patch_cli_add_overwrites_existing_file() -> anyhow::Result<()> { #[test] fn test_apply_patch_cli_delete_directory_fails() -> anyhow::Result<()> { let tmp = tempdir()?; - fs::create_dir(tmp.path().join("dir"))?; + let dir = tmp.path().join("dir"); + let expected_dir = resolved_under(tmp.path(), "dir")?; + fs::create_dir(&dir)?; apply_patch_command(tmp.path())? .arg("*** Begin Patch\n*** Delete File: dir\n*** End Patch") .assert() .failure() - .stderr("Failed to delete file dir\n"); + .stderr(format!( + "Failed to delete file {}\n", + expected_dir.display() + )); Ok(()) } @@ -243,13 +261,17 @@ fn test_apply_patch_cli_updates_file_appends_trailing_newline() -> anyhow::Resul fn test_apply_patch_cli_failure_after_partial_success_leaves_changes() -> anyhow::Result<()> { let tmp = tempdir()?; let new_file = tmp.path().join("created.txt"); + let missing_file = resolved_under(tmp.path(), "missing.txt")?; apply_patch_command(tmp.path())? .arg("*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch") .assert() .failure() .stdout("") - .stderr("Failed to read file to update missing.txt: No such file or directory (os error 2)\n"); + .stderr(format!( + "Failed to read file to update {}: No such file or directory (os error 2)\n", + missing_file.display() + )); assert_eq!(fs::read_to_string(&new_file)?, "hello\n"); diff --git a/codex-rs/arg0/Cargo.toml b/codex-rs/arg0/Cargo.toml index abe7277d94..8da0fcbd0b 100644 --- a/codex-rs/arg0/Cargo.toml +++ b/codex-rs/arg0/Cargo.toml @@ -14,8 +14,11 @@ workspace = true [dependencies] anyhow = { workspace = true } codex-apply-patch = { workspace = true } +codex-exec-server = { workspace = true } codex-linux-sandbox = { workspace = true } +codex-sandboxing = { workspace = true } codex-shell-escalation = { workspace = true } +codex-utils-absolute-path = { workspace = true } codex-utils-home-dir = { workspace = true } dotenvy = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index e210783a35..f8b61796b5 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -4,12 +4,12 @@ use std::path::Path; use std::path::PathBuf; use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; +use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; use codex_utils_home_dir::find_codex_home; #[cfg(unix)] use std::os::unix::fs::symlink; use tempfile::TempDir; -const LINUX_SANDBOX_ARG0: &str = "codex-linux-sandbox"; const APPLY_PATCH_ARG0: &str = "apply_patch"; const MISSPELLED_APPLY_PATCH_ARG0: &str = "applypatch"; #[cfg(unix)] @@ -19,6 +19,12 @@ const TOKIO_WORKER_STACK_SIZE_BYTES: usize = 16 * 1024 * 1024; #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct Arg0DispatchPaths { + /// Stable path to the current Codex executable for child re-execs. + /// + /// Prefer this over [`std::env::current_exe()`] in code that may run under + /// a test harness, where `current_exe()` can point at the harness binary + /// instead of the real Codex CLI. + pub codex_self_exe: Option, pub codex_linux_sandbox_exe: Option, pub main_execve_wrapper_exe: Option, } @@ -79,7 +85,7 @@ pub fn arg0_dispatch() -> Option { } } - if exe_name == LINUX_SANDBOX_ARG0 { + if exe_name == CODEX_LINUX_SANDBOX_ARG0 { // Safety: [`run_main`] never returns. codex_linux_sandbox::run_main(); } else if exe_name == APPLY_PATCH_ARG0 || exe_name == MISSPELLED_APPLY_PATCH_ARG0 { @@ -93,7 +99,24 @@ pub fn arg0_dispatch() -> Option { Some(patch_arg) => { let mut stdout = std::io::stdout(); let mut stderr = std::io::stderr(); - match codex_apply_patch::apply_patch(&patch_arg, &mut stdout, &mut stderr) { + let cwd = match codex_utils_absolute_path::AbsolutePathBuf::current_dir() { + Ok(cwd) => cwd, + Err(_) => std::process::exit(1), + }; + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(_) => std::process::exit(1), + }; + match runtime.block_on(codex_apply_patch::apply_patch( + &patch_arg, + &cwd, + &mut stdout, + &mut stderr, + codex_exec_server::LOCAL_FS.as_ref(), + )) { Ok(()) => 0, Err(_) => 1, } @@ -133,8 +156,10 @@ pub fn arg0_dispatch() -> Option { /// /// 1. Load `.env` values from `~/.codex/.env` before creating any threads. /// 2. Construct a Tokio multi-thread runtime. -/// 3. Derive the path to the current executable (so children can re-invoke the -/// sandbox) when running on Linux. +/// 3. Capture the current executable path and derive the +/// `codex-linux-sandbox` helper path (falling back to the current +/// executable if needed) so children can re-invoke the sandbox when running +/// on Linux. /// 4. Execute the provided async `main_fn` inside that runtime, forwarding any /// error. Note that `main_fn` receives [`Arg0DispatchPaths`], which /// contains the helper executable paths needed to construct @@ -150,7 +175,7 @@ where // Retain the TempDir so it exists for the lifetime of the invocation of // this executable. Admittedly, we could invoke `keep()` on it, but it // would be nice to avoid leaving temporary directories behind, if possible. - let path_entry = arg0_dispatch(); + let path_entry_guard = arg0_dispatch(); // Regular invocation – create a Tokio runtime and execute the provided // async entry-point. @@ -158,16 +183,13 @@ where runtime.block_on(async move { let current_exe = std::env::current_exe().ok(); let paths = Arg0DispatchPaths { + codex_self_exe: current_exe.clone(), codex_linux_sandbox_exe: if cfg!(target_os = "linux") { - current_exe.or_else(|| { - path_entry - .as_ref() - .and_then(|path_entry| path_entry.paths().codex_linux_sandbox_exe.clone()) - }) + linux_sandbox_exe_path(path_entry_guard.as_ref(), current_exe) } else { None }, - main_execve_wrapper_exe: path_entry + main_execve_wrapper_exe: path_entry_guard .as_ref() .and_then(|path_entry| path_entry.paths().main_execve_wrapper_exe.clone()), }; @@ -176,6 +198,18 @@ where }) } +fn linux_sandbox_exe_path( + path_entry_guard: Option<&Arg0PathEntryGuard>, + current_exe: Option, +) -> Option { + // Prefer the `codex-linux-sandbox` alias when available so callers can + // re-exec through a path whose basename still triggers arg0 dispatch on + // bubblewrap builds that do not support `--argv0`. + path_entry_guard + .and_then(|path_entry| path_entry.paths().codex_linux_sandbox_exe.clone()) + .or(current_exe) +} + fn build_runtime() -> anyhow::Result { let mut builder = tokio::runtime::Builder::new_multi_thread(); builder.enable_all(); @@ -276,7 +310,7 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result std::io::Result std::io::Result> { #[cfg(test)] mod tests { + use super::Arg0DispatchPaths; + use super::Arg0PathEntryGuard; use super::LOCK_FILENAME; use super::janitor_cleanup; + use super::linux_sandbox_exe_path; use std::fs; use std::fs::File; use std::path::Path; + use std::path::PathBuf; + use tempfile::TempDir; fn create_lock(dir: &Path) -> std::io::Result { let lock_path = dir.join(LOCK_FILENAME); @@ -413,6 +453,28 @@ mod tests { .open(lock_path) } + #[test] + fn linux_sandbox_exe_path_prefers_codex_linux_sandbox_alias() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let lock_file = create_lock(temp_dir.path())?; + let alias_path = temp_dir.path().join("codex-linux-sandbox"); + let path_entry = Arg0PathEntryGuard::new( + temp_dir, + lock_file, + Arg0DispatchPaths { + codex_self_exe: Some(PathBuf::from("/usr/bin/codex")), + codex_linux_sandbox_exe: Some(alias_path.clone()), + main_execve_wrapper_exe: None, + }, + ); + + assert_eq!( + linux_sandbox_exe_path(Some(&path_entry), Some(PathBuf::from("/usr/bin/codex"))), + Some(alias_path), + ); + Ok(()) + } + #[test] fn janitor_skips_dirs_without_lock_file() -> std::io::Result<()> { let root = tempfile::tempdir()?; diff --git a/codex-rs/artifacts/Cargo.toml b/codex-rs/artifacts/Cargo.toml deleted file mode 100644 index 0c5bbfc25b..0000000000 --- a/codex-rs/artifacts/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "codex-artifacts" -version.workspace = true -edition.workspace = true -license.workspace = true - -[dependencies] -codex-package-manager = { workspace = true } -reqwest = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -tempfile = { workspace = true } -thiserror = { workspace = true } -tokio = { workspace = true, features = ["fs", "io-util", "process", "time"] } -url = { workspace = true } -which = { workspace = true } - -[lints] -workspace = true - -[dev-dependencies] -flate2 = { workspace = true } -pretty_assertions = { workspace = true } -sha2 = { workspace = true } -tar = { workspace = true } -tokio = { workspace = true, features = ["fs", "io-util", "macros", "process", "rt", "rt-multi-thread", "time"] } -wiremock = { workspace = true } -zip = { workspace = true } diff --git a/codex-rs/artifacts/README.md b/codex-rs/artifacts/README.md deleted file mode 100644 index dae83f3e8e..0000000000 --- a/codex-rs/artifacts/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# codex-artifacts - -Runtime and process-management helpers for Codex artifact generation. - -This crate has two main responsibilities: - -- locating, validating, and optionally downloading the pinned artifact runtime -- spawning the artifact build or render command against that runtime - -## Module layout - -- `src/client.rs` - Runs build and render commands once a runtime has been resolved. -- `src/runtime/manager.rs` - Defines the release locator and the package-manager-backed runtime installer. -- `src/runtime/installed.rs` - Loads an extracted runtime from disk and validates its manifest and entrypoints. -- `src/runtime/js_runtime.rs` - Chooses the JavaScript executable to use for artifact execution. -- `src/runtime/manifest.rs` - Manifest types for release metadata and extracted runtimes. -- `src/runtime/error.rs` - Public runtime-loading and installation errors. -- `src/tests.rs` - Crate-level tests that exercise the public API and integration seams. - -## Public API - -- `ArtifactRuntimeManager` - Resolves or installs a runtime package into `~/.codex/packages/artifacts/...`. -- `load_cached_runtime` - Reads a previously installed runtime from a caller-provided cache root without attempting a download. -- `is_js_runtime_available` - Checks whether artifact execution is possible with either a cached runtime or a host JS runtime. -- `ArtifactsClient` - Executes artifact build or render requests using either a managed or preinstalled runtime. diff --git a/codex-rs/artifacts/src/client.rs b/codex-rs/artifacts/src/client.rs deleted file mode 100644 index d0a10ed129..0000000000 --- a/codex-rs/artifacts/src/client.rs +++ /dev/null @@ -1,229 +0,0 @@ -use crate::ArtifactRuntimeError; -use crate::ArtifactRuntimeManager; -use crate::InstalledArtifactRuntime; -use std::collections::BTreeMap; -use std::path::PathBuf; -use std::process::Stdio; -use std::time::Duration; -use tempfile::TempDir; -use thiserror::Error; -use tokio::fs; -use tokio::io::AsyncReadExt; -use tokio::process::Command; -use tokio::time::timeout; -use url::Url; - -const DEFAULT_EXECUTION_TIMEOUT: Duration = Duration::from_secs(30); - -/// Executes artifact build commands against a resolved runtime. -#[derive(Clone, Debug)] -pub struct ArtifactsClient { - runtime_source: RuntimeSource, -} - -#[derive(Clone, Debug)] -#[allow(clippy::large_enum_variant)] -enum RuntimeSource { - Managed(ArtifactRuntimeManager), - Installed(InstalledArtifactRuntime), -} - -impl ArtifactsClient { - /// Creates a client that lazily resolves or downloads the runtime on demand. - pub fn from_runtime_manager(runtime_manager: ArtifactRuntimeManager) -> Self { - Self { - runtime_source: RuntimeSource::Managed(runtime_manager), - } - } - - /// Creates a client pinned to an already loaded runtime. - pub fn from_installed_runtime(runtime: InstalledArtifactRuntime) -> Self { - Self { - runtime_source: RuntimeSource::Installed(runtime), - } - } - - /// Executes artifact-building JavaScript against the configured runtime. - pub async fn execute_build( - &self, - request: ArtifactBuildRequest, - ) -> Result { - let runtime = self.resolve_runtime().await?; - let js_runtime = runtime.resolve_js_runtime()?; - let staging_dir = TempDir::new().map_err(|source| ArtifactsError::Io { - context: "failed to create build staging directory".to_string(), - source, - })?; - let script_path = staging_dir.path().join("artifact-build.mjs"); - let build_entrypoint_url = - Url::from_file_path(runtime.build_js_path()).map_err(|()| ArtifactsError::Io { - context: format!( - "failed to convert artifact build entrypoint to a file URL: {}", - runtime.build_js_path().display() - ), - source: std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "invalid artifact build entrypoint path", - ), - })?; - let wrapped_script = build_wrapped_script(&build_entrypoint_url, &request.source); - fs::write(&script_path, wrapped_script) - .await - .map_err(|source| ArtifactsError::Io { - context: format!("failed to write {}", script_path.display()), - source, - })?; - - let mut command = Command::new(js_runtime.executable_path()); - command.arg(&script_path).current_dir(&request.cwd); - command.stdout(Stdio::piped()).stderr(Stdio::piped()); - if js_runtime.requires_electron_run_as_node() { - command.env("ELECTRON_RUN_AS_NODE", "1"); - } - for (key, value) in &request.env { - command.env(key, value); - } - - run_command( - command, - request.timeout.unwrap_or(DEFAULT_EXECUTION_TIMEOUT), - ) - .await - } - - async fn resolve_runtime(&self) -> Result { - match &self.runtime_source { - RuntimeSource::Installed(runtime) => Ok(runtime.clone()), - RuntimeSource::Managed(manager) => manager.ensure_installed().await.map_err(Into::into), - } - } -} - -/// Request payload for the artifact build command. -#[derive(Clone, Debug, Default)] -pub struct ArtifactBuildRequest { - pub source: String, - pub cwd: PathBuf, - pub timeout: Option, - pub env: BTreeMap, -} - -/// Captured stdout, stderr, and exit status from an artifact subprocess. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ArtifactCommandOutput { - pub exit_code: Option, - pub stdout: String, - pub stderr: String, -} - -impl ArtifactCommandOutput { - /// Returns whether the subprocess exited successfully. - pub fn success(&self) -> bool { - self.exit_code == Some(0) - } -} - -/// Errors raised while spawning or awaiting artifact subprocesses. -#[derive(Debug, Error)] -pub enum ArtifactsError { - #[error(transparent)] - Runtime(#[from] ArtifactRuntimeError), - #[error("{context}")] - Io { - context: String, - #[source] - source: std::io::Error, - }, - #[error("artifact command timed out after {timeout:?}")] - TimedOut { timeout: Duration }, -} - -fn build_wrapped_script(build_entrypoint_url: &Url, source: &str) -> String { - let mut wrapped = String::new(); - wrapped.push_str("const artifactTool = await import("); - wrapped.push_str( - &serde_json::to_string(build_entrypoint_url.as_str()).unwrap_or_else(|error| { - panic!("artifact build entrypoint URL must serialize: {error}") - }), - ); - wrapped.push_str(");\n"); - wrapped.push_str( - r#"globalThis.artifactTool = artifactTool; -for (const [name, value] of Object.entries(artifactTool)) { - if (name === "default" || Object.prototype.hasOwnProperty.call(globalThis, name)) { - continue; - } - globalThis[name] = value; -} -"#, - ); - wrapped.push_str(source); - wrapped.push('\n'); - wrapped -} - -async fn run_command( - mut command: Command, - execution_timeout: Duration, -) -> Result { - let mut child = command.spawn().map_err(|source| ArtifactsError::Io { - context: "failed to spawn artifact command".to_string(), - source, - })?; - let mut stdout = child.stdout.take().ok_or_else(|| ArtifactsError::Io { - context: "artifact command stdout was not captured".to_string(), - source: std::io::Error::other("missing stdout pipe"), - })?; - let mut stderr = child.stderr.take().ok_or_else(|| ArtifactsError::Io { - context: "artifact command stderr was not captured".to_string(), - source: std::io::Error::other("missing stderr pipe"), - })?; - let stdout_task = tokio::spawn(async move { - let mut bytes = Vec::new(); - stdout.read_to_end(&mut bytes).await.map(|_| bytes) - }); - let stderr_task = tokio::spawn(async move { - let mut bytes = Vec::new(); - stderr.read_to_end(&mut bytes).await.map(|_| bytes) - }); - - let status = match timeout(execution_timeout, child.wait()).await { - Ok(result) => result.map_err(|source| ArtifactsError::Io { - context: "failed while waiting for artifact command".to_string(), - source, - })?, - Err(_) => { - let _ = child.kill().await; - let _ = child.wait().await; - return Err(ArtifactsError::TimedOut { - timeout: execution_timeout, - }); - } - }; - let stdout_bytes = stdout_task - .await - .map_err(|source| ArtifactsError::Io { - context: "failed to join stdout reader".to_string(), - source: std::io::Error::other(source.to_string()), - })? - .map_err(|source| ArtifactsError::Io { - context: "failed to read artifact command stdout".to_string(), - source, - })?; - let stderr_bytes = stderr_task - .await - .map_err(|source| ArtifactsError::Io { - context: "failed to join stderr reader".to_string(), - source: std::io::Error::other(source.to_string()), - })? - .map_err(|source| ArtifactsError::Io { - context: "failed to read artifact command stderr".to_string(), - source, - })?; - - Ok(ArtifactCommandOutput { - exit_code: status.code(), - stdout: String::from_utf8_lossy(&stdout_bytes).into_owned(), - stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(), - }) -} diff --git a/codex-rs/artifacts/src/lib.rs b/codex-rs/artifacts/src/lib.rs deleted file mode 100644 index 812c3db853..0000000000 --- a/codex-rs/artifacts/src/lib.rs +++ /dev/null @@ -1,24 +0,0 @@ -mod client; -mod runtime; -#[cfg(all(test, not(windows)))] -mod tests; - -pub use client::ArtifactBuildRequest; -pub use client::ArtifactCommandOutput; -pub use client::ArtifactsClient; -pub use client::ArtifactsError; -pub use runtime::ArtifactRuntimeError; -pub use runtime::ArtifactRuntimeManager; -pub use runtime::ArtifactRuntimeManagerConfig; -pub use runtime::ArtifactRuntimePlatform; -pub use runtime::ArtifactRuntimeReleaseLocator; -pub use runtime::DEFAULT_CACHE_ROOT_RELATIVE; -pub use runtime::DEFAULT_RELEASE_BASE_URL; -pub use runtime::DEFAULT_RELEASE_TAG_PREFIX; -pub use runtime::InstalledArtifactRuntime; -pub use runtime::JsRuntime; -pub use runtime::JsRuntimeKind; -pub use runtime::ReleaseManifest; -pub use runtime::can_manage_artifact_runtime; -pub use runtime::is_js_runtime_available; -pub use runtime::load_cached_runtime; diff --git a/codex-rs/artifacts/src/runtime/error.rs b/codex-rs/artifacts/src/runtime/error.rs deleted file mode 100644 index 9a7090d468..0000000000 --- a/codex-rs/artifacts/src/runtime/error.rs +++ /dev/null @@ -1,28 +0,0 @@ -use codex_package_manager::PackageManagerError; -use std::path::PathBuf; -use thiserror::Error; - -/// Errors raised while locating, validating, or installing an artifact runtime. -#[derive(Debug, Error)] -pub enum ArtifactRuntimeError { - #[error(transparent)] - PackageManager(#[from] PackageManagerError), - #[error("{context}")] - Io { - context: String, - #[source] - source: std::io::Error, - }, - #[error("invalid package metadata at {path}")] - InvalidPackageMetadata { - path: PathBuf, - #[source] - source: serde_json::Error, - }, - #[error("runtime path `{0}` is invalid")] - InvalidRuntimePath(String), - #[error( - "no compatible JavaScript runtime found for artifact runtime at {root_dir}; install Node or the Codex desktop app" - )] - MissingJsRuntime { root_dir: PathBuf }, -} diff --git a/codex-rs/artifacts/src/runtime/installed.rs b/codex-rs/artifacts/src/runtime/installed.rs deleted file mode 100644 index 76e3c6d132..0000000000 --- a/codex-rs/artifacts/src/runtime/installed.rs +++ /dev/null @@ -1,283 +0,0 @@ -use super::ArtifactRuntimeError; -use super::ArtifactRuntimePlatform; -use super::JsRuntime; -use super::codex_app_runtime_candidates; -use super::resolve_js_runtime_from_candidates; -use super::system_electron_runtime; -use super::system_node_runtime; -use std::collections::BTreeMap; -use std::path::Component; -use std::path::Path; -use std::path::PathBuf; - -const ARTIFACT_TOOL_PACKAGE_NAME: &str = "@oai/artifact-tool"; - -/// Loads a previously installed runtime from a caller-provided cache root. -pub fn load_cached_runtime( - cache_root: &Path, - runtime_version: &str, -) -> Result { - let platform = ArtifactRuntimePlatform::detect_current()?; - let install_dir = cached_runtime_install_dir(cache_root, runtime_version, platform); - if !install_dir.exists() { - return Err(ArtifactRuntimeError::Io { - context: format!( - "artifact runtime {runtime_version} is not installed at {}", - install_dir.display() - ), - source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing artifact runtime"), - }); - } - - InstalledArtifactRuntime::load(install_dir, platform) -} - -/// A validated runtime installation extracted into the local package cache. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct InstalledArtifactRuntime { - root_dir: PathBuf, - runtime_version: String, - platform: ArtifactRuntimePlatform, - build_js_path: PathBuf, -} - -impl InstalledArtifactRuntime { - /// Creates an installed-runtime value from prevalidated paths. - pub fn new( - root_dir: PathBuf, - runtime_version: String, - platform: ArtifactRuntimePlatform, - build_js_path: PathBuf, - ) -> Self { - Self { - root_dir, - runtime_version, - platform, - build_js_path, - } - } - - /// Loads and validates an extracted runtime directory. - pub fn load( - root_dir: PathBuf, - platform: ArtifactRuntimePlatform, - ) -> Result { - let package_metadata = load_package_metadata(&root_dir)?; - let build_js_path = - resolve_relative_runtime_path(&root_dir, &package_metadata.build_js_relative_path)?; - verify_required_runtime_path(&build_js_path)?; - - Ok(Self::new( - root_dir, - package_metadata.version, - platform, - build_js_path, - )) - } - - /// Returns the extracted runtime root directory. - pub fn root_dir(&self) -> &Path { - &self.root_dir - } - - /// Returns the runtime version recorded in `package.json`. - pub fn runtime_version(&self) -> &str { - &self.runtime_version - } - - /// Returns the platform this runtime was installed for. - pub fn platform(&self) -> ArtifactRuntimePlatform { - self.platform - } - - /// Returns the artifact build entrypoint path. - pub fn build_js_path(&self) -> &Path { - &self.build_js_path - } - - /// Resolves the best executable to use for artifact commands. - /// - /// Preference order is a machine Node install, then Electron from the - /// machine or a Codex desktop app bundle. - pub fn resolve_js_runtime(&self) -> Result { - resolve_js_runtime_from_candidates( - system_node_runtime(), - system_electron_runtime(), - codex_app_runtime_candidates(), - ) - .ok_or_else(|| ArtifactRuntimeError::MissingJsRuntime { - root_dir: self.root_dir.clone(), - }) - } -} - -pub(crate) fn cached_runtime_install_dir( - cache_root: &Path, - runtime_version: &str, - platform: ArtifactRuntimePlatform, -) -> PathBuf { - cache_root.join(runtime_version).join(platform.as_str()) -} - -pub(crate) fn default_cached_runtime_root(codex_home: &Path) -> PathBuf { - codex_home.join(super::DEFAULT_CACHE_ROOT_RELATIVE) -} - -fn resolve_relative_runtime_path( - root_dir: &Path, - relative_path: &str, -) -> Result { - let relative = Path::new(relative_path); - if relative.as_os_str().is_empty() || relative.is_absolute() { - return Err(ArtifactRuntimeError::InvalidRuntimePath( - relative_path.to_string(), - )); - } - if relative.components().any(|component| { - matches!( - component, - Component::ParentDir | Component::Prefix(_) | Component::RootDir - ) - }) { - return Err(ArtifactRuntimeError::InvalidRuntimePath( - relative_path.to_string(), - )); - } - Ok(root_dir.join(relative)) -} - -fn verify_required_runtime_path(path: &Path) -> Result<(), ArtifactRuntimeError> { - if path.is_file() { - return Ok(()); - } - - Err(ArtifactRuntimeError::Io { - context: format!("required runtime file is missing: {}", path.display()), - source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing runtime file"), - }) -} - -pub(crate) fn detect_runtime_root(extraction_root: &Path) -> Result { - if is_runtime_root(extraction_root) { - return Ok(extraction_root.to_path_buf()); - } - - let mut directory_candidates = Vec::new(); - for entry in std::fs::read_dir(extraction_root).map_err(|source| ArtifactRuntimeError::Io { - context: format!("failed to read {}", extraction_root.display()), - source, - })? { - let entry = entry.map_err(|source| ArtifactRuntimeError::Io { - context: format!("failed to read entry in {}", extraction_root.display()), - source, - })?; - let path = entry.path(); - if path.is_dir() { - directory_candidates.push(path); - } - } - - if directory_candidates.len() == 1 { - let candidate = &directory_candidates[0]; - if is_runtime_root(candidate) { - return Ok(candidate.clone()); - } - } - - Err(ArtifactRuntimeError::Io { - context: format!( - "failed to detect artifact runtime root under {}", - extraction_root.display() - ), - source: std::io::Error::new( - std::io::ErrorKind::NotFound, - "missing artifact runtime root", - ), - }) -} - -fn is_runtime_root(root_dir: &Path) -> bool { - let Ok(package_metadata) = load_package_metadata(root_dir) else { - return false; - }; - let Ok(build_js_path) = - resolve_relative_runtime_path(root_dir, &package_metadata.build_js_relative_path) - else { - return false; - }; - - build_js_path.is_file() -} - -struct PackageMetadata { - version: String, - build_js_relative_path: String, -} - -fn load_package_metadata(root_dir: &Path) -> Result { - #[derive(serde::Deserialize)] - struct PackageJson { - name: String, - version: String, - exports: PackageExports, - } - - #[derive(serde::Deserialize)] - #[serde(untagged)] - enum PackageExports { - Main(String), - Map(BTreeMap), - } - - impl PackageExports { - fn build_entrypoint(&self) -> Option<&str> { - match self { - Self::Main(path) => Some(path), - Self::Map(exports) => exports.get(".").map(String::as_str), - } - } - } - - let package_json_path = root_dir.join("package.json"); - let package_json_bytes = - std::fs::read(&package_json_path).map_err(|source| ArtifactRuntimeError::Io { - context: format!("failed to read {}", package_json_path.display()), - source, - })?; - let package_json = - serde_json::from_slice::(&package_json_bytes).map_err(|source| { - ArtifactRuntimeError::InvalidPackageMetadata { - path: package_json_path.clone(), - source, - } - })?; - - if package_json.name != ARTIFACT_TOOL_PACKAGE_NAME { - return Err(ArtifactRuntimeError::Io { - context: format!( - "unsupported artifact runtime package at {}; expected name `{ARTIFACT_TOOL_PACKAGE_NAME}`, got `{}`", - package_json_path.display(), - package_json.name - ), - source: std::io::Error::new( - std::io::ErrorKind::InvalidData, - "unsupported package name", - ), - }); - } - - let Some(build_js_relative_path) = package_json.exports.build_entrypoint() else { - return Err(ArtifactRuntimeError::Io { - context: format!( - "unsupported artifact runtime package at {}; expected `exports[\".\"]` to point at the JS entrypoint", - package_json_path.display() - ), - source: std::io::Error::new(std::io::ErrorKind::InvalidData, "missing package export"), - }); - }; - - Ok(PackageMetadata { - version: package_json.version, - build_js_relative_path: build_js_relative_path.trim_start_matches("./").to_string(), - }) -} diff --git a/codex-rs/artifacts/src/runtime/js_runtime.rs b/codex-rs/artifacts/src/runtime/js_runtime.rs deleted file mode 100644 index 228747e473..0000000000 --- a/codex-rs/artifacts/src/runtime/js_runtime.rs +++ /dev/null @@ -1,171 +0,0 @@ -use crate::ArtifactRuntimePlatform; -use crate::runtime::default_cached_runtime_root; -use crate::runtime::load_cached_runtime; -use std::path::Path; -use std::path::PathBuf; -use which::which; - -const CODEX_APP_PRODUCT_NAMES: [&str; 6] = [ - "Codex", - "Codex (Dev)", - "Codex (Agent)", - "Codex (Nightly)", - "Codex (Alpha)", - "Codex (Beta)", -]; - -/// The JavaScript runtime used to execute the artifact tool. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum JsRuntimeKind { - Node, - Electron, -} - -/// A discovered JavaScript executable and the way it should be invoked. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct JsRuntime { - executable_path: PathBuf, - kind: JsRuntimeKind, -} - -impl JsRuntime { - pub(crate) fn node(executable_path: PathBuf) -> Self { - Self { - executable_path, - kind: JsRuntimeKind::Node, - } - } - - pub(crate) fn electron(executable_path: PathBuf) -> Self { - Self { - executable_path, - kind: JsRuntimeKind::Electron, - } - } - - /// Returns the executable to spawn for artifact commands. - pub fn executable_path(&self) -> &Path { - &self.executable_path - } - - /// Returns whether the command must set `ELECTRON_RUN_AS_NODE=1`. - pub fn requires_electron_run_as_node(&self) -> bool { - self.kind == JsRuntimeKind::Electron - } -} - -/// Returns `true` when artifact execution can find both runtime assets and a JS executable. -pub fn is_js_runtime_available(codex_home: &Path, runtime_version: &str) -> bool { - load_cached_runtime(&default_cached_runtime_root(codex_home), runtime_version) - .ok() - .and_then(|runtime| runtime.resolve_js_runtime().ok()) - .or_else(resolve_machine_js_runtime) - .is_some() -} - -/// Returns `true` when this machine can use the managed artifact runtime flow. -/// -/// This is a platform capability check, not a cache or binary availability check. -/// Callers that rely on `ArtifactRuntimeManager::ensure_installed()` should use this -/// to decide whether the feature can be exposed on the current machine. -pub fn can_manage_artifact_runtime() -> bool { - ArtifactRuntimePlatform::detect_current().is_ok() -} - -pub(crate) fn resolve_machine_js_runtime() -> Option { - resolve_js_runtime_from_candidates( - system_node_runtime(), - system_electron_runtime(), - codex_app_runtime_candidates(), - ) -} - -pub(crate) fn resolve_js_runtime_from_candidates( - node_runtime: Option, - electron_runtime: Option, - codex_app_candidates: Vec, -) -> Option { - node_runtime.or(electron_runtime).or_else(|| { - codex_app_candidates - .into_iter() - .find_map(|candidate| electron_runtime_from_path(&candidate)) - }) -} - -pub(crate) fn system_node_runtime() -> Option { - which("node") - .ok() - .and_then(|path| node_runtime_from_path(&path)) -} - -pub(crate) fn system_electron_runtime() -> Option { - which("electron") - .ok() - .and_then(|path| electron_runtime_from_path(&path)) -} - -pub(crate) fn node_runtime_from_path(path: &Path) -> Option { - path.is_file().then(|| JsRuntime::node(path.to_path_buf())) -} - -pub(crate) fn electron_runtime_from_path(path: &Path) -> Option { - path.is_file() - .then(|| JsRuntime::electron(path.to_path_buf())) -} - -pub(crate) fn codex_app_runtime_candidates() -> Vec { - match std::env::consts::OS { - "macos" => { - let mut roots = vec![PathBuf::from("/Applications")]; - if let Some(home) = std::env::var_os("HOME") { - roots.push(PathBuf::from(home).join("Applications")); - } - - roots - .into_iter() - .flat_map(|root| { - CODEX_APP_PRODUCT_NAMES - .into_iter() - .map(move |product_name| { - root.join(format!("{product_name}.app")) - .join("Contents") - .join("MacOS") - .join(product_name) - }) - }) - .collect() - } - "windows" => { - let mut roots = Vec::new(); - if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") { - roots.push(PathBuf::from(local_app_data).join("Programs")); - } - if let Some(program_files) = std::env::var_os("ProgramFiles") { - roots.push(PathBuf::from(program_files)); - } - if let Some(program_files_x86) = std::env::var_os("ProgramFiles(x86)") { - roots.push(PathBuf::from(program_files_x86)); - } - - roots - .into_iter() - .flat_map(|root| { - CODEX_APP_PRODUCT_NAMES - .into_iter() - .map(move |product_name| { - root.join(product_name).join(format!("{product_name}.exe")) - }) - }) - .collect() - } - "linux" => [PathBuf::from("/opt"), PathBuf::from("/usr/lib")] - .into_iter() - .flat_map(|root| { - CODEX_APP_PRODUCT_NAMES - .into_iter() - .map(move |product_name| root.join(product_name).join(product_name)) - }) - .collect(), - _ => Vec::new(), - } -} diff --git a/codex-rs/artifacts/src/runtime/manager.rs b/codex-rs/artifacts/src/runtime/manager.rs deleted file mode 100644 index b0a1c60ef3..0000000000 --- a/codex-rs/artifacts/src/runtime/manager.rs +++ /dev/null @@ -1,255 +0,0 @@ -use super::ArtifactRuntimeError; -use super::ArtifactRuntimePlatform; -use super::InstalledArtifactRuntime; -use super::ReleaseManifest; -use super::detect_runtime_root; -use codex_package_manager::ManagedPackage; -use codex_package_manager::PackageManager; -use codex_package_manager::PackageManagerConfig; -use codex_package_manager::PackageManagerError; -use codex_package_manager::PackageReleaseArchive; -use reqwest::Client; -use std::path::Path; -use std::path::PathBuf; -use url::Url; - -/// Release tag prefix used for artifact runtime assets. -pub const DEFAULT_RELEASE_TAG_PREFIX: &str = "artifact-runtime-v"; - -/// Relative cache root for installed artifact runtimes under `codex_home`. -pub const DEFAULT_CACHE_ROOT_RELATIVE: &str = "packages/artifacts"; - -/// Base URL used by default when downloading runtime assets from GitHub releases. -pub const DEFAULT_RELEASE_BASE_URL: &str = "https://github.com/openai/codex/releases/download/"; - -/// Describes where a particular artifact runtime release can be downloaded from. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ArtifactRuntimeReleaseLocator { - base_url: Url, - runtime_version: String, - release_tag_prefix: String, -} - -impl ArtifactRuntimeReleaseLocator { - /// Creates a locator for a runtime version under a release base URL. - pub fn new(base_url: Url, runtime_version: impl Into) -> Self { - Self { - base_url, - runtime_version: runtime_version.into(), - release_tag_prefix: DEFAULT_RELEASE_TAG_PREFIX.to_string(), - } - } - - /// Overrides the release-tag prefix used when constructing asset names. - pub fn with_tag_prefix(mut self, release_tag_prefix: impl Into) -> Self { - self.release_tag_prefix = release_tag_prefix.into(); - self - } - - /// Returns the release asset base URL. - pub fn base_url(&self) -> &Url { - &self.base_url - } - - /// Returns the expected runtime version. - pub fn runtime_version(&self) -> &str { - &self.runtime_version - } - - /// Returns the full release tag for the runtime version. - pub fn release_tag(&self) -> String { - format!("{}{}", self.release_tag_prefix, self.runtime_version) - } - - /// Returns the expected manifest filename for the release. - pub fn manifest_file_name(&self) -> String { - format!("{}-manifest.json", self.release_tag()) - } - - /// Returns the manifest URL for this runtime release. - pub fn manifest_url(&self) -> Result { - self.base_url - .join(&format!( - "{}/{}", - self.release_tag(), - self.manifest_file_name() - )) - .map_err(PackageManagerError::InvalidBaseUrl) - } - - /// Returns the default GitHub-release locator for a runtime version. - pub fn default(runtime_version: impl Into) -> Self { - Self::new( - Url::parse(DEFAULT_RELEASE_BASE_URL).unwrap_or_else(|error| { - panic!("hard-coded artifact runtime release base URL must be valid: {error}") - }), - runtime_version, - ) - } -} - -/// Configuration for resolving artifact runtimes under a Codex home directory. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ArtifactRuntimeManagerConfig { - package_manager: PackageManagerConfig, - release: ArtifactRuntimeReleaseLocator, -} - -impl ArtifactRuntimeManagerConfig { - /// Creates a runtime-manager config from a Codex home and explicit release locator. - pub fn new(codex_home: PathBuf, release: ArtifactRuntimeReleaseLocator) -> Self { - Self { - package_manager: PackageManagerConfig::new( - codex_home, - ArtifactRuntimePackage::new(release.clone()), - ), - release, - } - } - - /// Creates a runtime-manager config that downloads from the default GitHub release location. - pub fn with_default_release(codex_home: PathBuf, runtime_version: impl Into) -> Self { - Self::new( - codex_home, - ArtifactRuntimeReleaseLocator::default(runtime_version), - ) - } - - /// Overrides the runtime cache root. - pub fn with_cache_root(mut self, cache_root: PathBuf) -> Self { - self.package_manager = self.package_manager.with_cache_root(cache_root); - self - } - - /// Returns the runtime cache root. - pub fn cache_root(&self) -> PathBuf { - self.package_manager.cache_root() - } - - /// Returns the release locator used by this config. - pub fn release(&self) -> &ArtifactRuntimeReleaseLocator { - &self.release - } -} - -/// Package-manager-backed artifact runtime resolver and installer. -#[derive(Clone, Debug)] -pub struct ArtifactRuntimeManager { - package_manager: PackageManager, - config: ArtifactRuntimeManagerConfig, -} - -impl ArtifactRuntimeManager { - /// Creates a runtime manager using the default `reqwest` client. - pub fn new(config: ArtifactRuntimeManagerConfig) -> Self { - let package_manager = PackageManager::new(config.package_manager.clone()); - Self { - package_manager, - config, - } - } - - /// Creates a runtime manager with a caller-provided HTTP client. - pub fn with_client(config: ArtifactRuntimeManagerConfig, client: Client) -> Self { - let package_manager = PackageManager::with_client(config.package_manager.clone(), client); - Self { - package_manager, - config, - } - } - - /// Returns the manager configuration. - pub fn config(&self) -> &ArtifactRuntimeManagerConfig { - &self.config - } - - /// Returns the installed runtime if it is already present and valid. - pub async fn resolve_cached( - &self, - ) -> Result, ArtifactRuntimeError> { - self.package_manager.resolve_cached().await - } - - /// Returns the installed runtime, downloading and caching it if necessary. - pub async fn ensure_installed(&self) -> Result { - self.package_manager.ensure_installed().await - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct ArtifactRuntimePackage { - release: ArtifactRuntimeReleaseLocator, -} - -impl ArtifactRuntimePackage { - fn new(release: ArtifactRuntimeReleaseLocator) -> Self { - Self { release } - } -} - -impl ManagedPackage for ArtifactRuntimePackage { - type Error = ArtifactRuntimeError; - type Installed = InstalledArtifactRuntime; - type ReleaseManifest = ReleaseManifest; - - fn default_cache_root_relative(&self) -> &str { - DEFAULT_CACHE_ROOT_RELATIVE - } - - fn version(&self) -> &str { - self.release.runtime_version() - } - - fn manifest_url(&self) -> Result { - self.release.manifest_url() - } - - fn archive_url(&self, archive: &PackageReleaseArchive) -> Result { - self.release - .base_url() - .join(&format!( - "{}/{}", - self.release.release_tag(), - archive.archive - )) - .map_err(PackageManagerError::InvalidBaseUrl) - } - - fn release_version<'a>(&self, manifest: &'a Self::ReleaseManifest) -> &'a str { - &manifest.runtime_version - } - - fn platform_archive( - &self, - manifest: &Self::ReleaseManifest, - platform: ArtifactRuntimePlatform, - ) -> Result { - manifest - .platforms - .get(platform.as_str()) - .cloned() - .ok_or_else(|| { - PackageManagerError::MissingPlatform(platform.as_str().to_string()).into() - }) - } - - fn install_dir(&self, cache_root: &Path, platform: ArtifactRuntimePlatform) -> PathBuf { - cache_root.join(self.version()).join(platform.as_str()) - } - - fn installed_version<'a>(&self, package: &'a Self::Installed) -> &'a str { - package.runtime_version() - } - - fn load_installed( - &self, - root_dir: PathBuf, - platform: ArtifactRuntimePlatform, - ) -> Result { - InstalledArtifactRuntime::load(root_dir, platform) - } - - fn detect_extracted_root(&self, extraction_root: &Path) -> Result { - detect_runtime_root(extraction_root) - } -} diff --git a/codex-rs/artifacts/src/runtime/manifest.rs b/codex-rs/artifacts/src/runtime/manifest.rs deleted file mode 100644 index ad02afa898..0000000000 --- a/codex-rs/artifacts/src/runtime/manifest.rs +++ /dev/null @@ -1,15 +0,0 @@ -use codex_package_manager::PackageReleaseArchive; -use serde::Deserialize; -use serde::Serialize; -use std::collections::BTreeMap; - -/// Release metadata published alongside the packaged artifact runtime. -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -pub struct ReleaseManifest { - pub schema_version: u32, - pub runtime_version: String, - pub release_tag: String, - #[serde(default)] - pub node_version: Option, - pub platforms: BTreeMap, -} diff --git a/codex-rs/artifacts/src/runtime/mod.rs b/codex-rs/artifacts/src/runtime/mod.rs deleted file mode 100644 index 41fd1a48fc..0000000000 --- a/codex-rs/artifacts/src/runtime/mod.rs +++ /dev/null @@ -1,28 +0,0 @@ -mod error; -mod installed; -mod js_runtime; -mod manager; -mod manifest; - -pub use codex_package_manager::PackagePlatform as ArtifactRuntimePlatform; -pub use error::ArtifactRuntimeError; -pub use installed::InstalledArtifactRuntime; -pub use installed::load_cached_runtime; -pub use js_runtime::JsRuntime; -pub use js_runtime::JsRuntimeKind; -pub use js_runtime::can_manage_artifact_runtime; -pub use js_runtime::is_js_runtime_available; -pub use manager::ArtifactRuntimeManager; -pub use manager::ArtifactRuntimeManagerConfig; -pub use manager::ArtifactRuntimeReleaseLocator; -pub use manager::DEFAULT_CACHE_ROOT_RELATIVE; -pub use manager::DEFAULT_RELEASE_BASE_URL; -pub use manager::DEFAULT_RELEASE_TAG_PREFIX; -pub use manifest::ReleaseManifest; - -pub(crate) use installed::default_cached_runtime_root; -pub(crate) use installed::detect_runtime_root; -pub(crate) use js_runtime::codex_app_runtime_candidates; -pub(crate) use js_runtime::resolve_js_runtime_from_candidates; -pub(crate) use js_runtime::system_electron_runtime; -pub(crate) use js_runtime::system_node_runtime; diff --git a/codex-rs/artifacts/src/tests.rs b/codex-rs/artifacts/src/tests.rs deleted file mode 100644 index 3db8a0bcc2..0000000000 --- a/codex-rs/artifacts/src/tests.rs +++ /dev/null @@ -1,453 +0,0 @@ -use crate::ArtifactBuildRequest; -use crate::ArtifactCommandOutput; -use crate::ArtifactRuntimeManager; -use crate::ArtifactRuntimeManagerConfig; -use crate::ArtifactRuntimePlatform; -use crate::ArtifactRuntimeReleaseLocator; -use crate::ArtifactsClient; -use crate::DEFAULT_CACHE_ROOT_RELATIVE; -use crate::ReleaseManifest; -use crate::load_cached_runtime; -use codex_package_manager::ArchiveFormat; -use codex_package_manager::PackageReleaseArchive; -use flate2::Compression; -use flate2::write::GzEncoder; -use pretty_assertions::assert_eq; -use sha2::Digest; -use sha2::Sha256; -use std::collections::BTreeMap; -use std::fs; -use std::io::Cursor; -use std::io::Write; -use std::path::Path; -use std::time::Duration; -use tar::Builder as TarBuilder; -use tempfile::TempDir; -use wiremock::Mock; -use wiremock::MockServer; -use wiremock::ResponseTemplate; -use wiremock::matchers::method; -use wiremock::matchers::path; -use zip::ZipWriter; -use zip::write::SimpleFileOptions; - -#[test] -fn release_locator_builds_manifest_url() { - let locator = ArtifactRuntimeReleaseLocator::new( - url::Url::parse("https://example.test/releases/").unwrap_or_else(|error| panic!("{error}")), - "0.1.0", - ); - let url = locator - .manifest_url() - .unwrap_or_else(|error| panic!("{error}")); - assert_eq!( - url.as_str(), - "https://example.test/releases/artifact-runtime-v0.1.0/artifact-runtime-v0.1.0-manifest.json" - ); -} - -#[test] -fn default_release_locator_uses_openai_codex_github_releases() { - let locator = ArtifactRuntimeReleaseLocator::default("0.1.0"); - let url = locator - .manifest_url() - .unwrap_or_else(|error| panic!("{error}")); - - assert_eq!( - url.as_str(), - "https://github.com/openai/codex/releases/download/artifact-runtime-v0.1.0/artifact-runtime-v0.1.0-manifest.json" - ); -} - -#[test] -fn load_cached_runtime_reads_installed_runtime() { - let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}")); - let runtime_version = "2.5.6"; - let platform = - ArtifactRuntimePlatform::detect_current().unwrap_or_else(|error| panic!("{error}")); - let install_dir = codex_home - .path() - .join(DEFAULT_CACHE_ROOT_RELATIVE) - .join(runtime_version) - .join(platform.as_str()); - write_installed_runtime(&install_dir, runtime_version); - - let runtime = load_cached_runtime( - &codex_home.path().join(DEFAULT_CACHE_ROOT_RELATIVE), - runtime_version, - ) - .unwrap_or_else(|error| panic!("{error}")); - - assert_eq!(runtime.runtime_version(), runtime_version); - assert_eq!(runtime.platform(), platform); - assert!( - runtime - .build_js_path() - .ends_with(Path::new("dist/artifact_tool.mjs")) - ); -} - -#[test] -fn load_cached_runtime_requires_build_entrypoint() { - let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}")); - let runtime_version = "2.5.6"; - let platform = - ArtifactRuntimePlatform::detect_current().unwrap_or_else(|error| panic!("{error}")); - let install_dir = codex_home - .path() - .join(DEFAULT_CACHE_ROOT_RELATIVE) - .join(runtime_version) - .join(platform.as_str()); - write_installed_runtime(&install_dir, runtime_version); - fs::remove_file(install_dir.join("dist/artifact_tool.mjs")) - .unwrap_or_else(|error| panic!("{error}")); - - let error = load_cached_runtime( - &codex_home.path().join(DEFAULT_CACHE_ROOT_RELATIVE), - runtime_version, - ) - .unwrap_err(); - - assert_eq!( - error.to_string(), - format!( - "required runtime file is missing: {}", - install_dir.join("dist/artifact_tool.mjs").display() - ) - ); -} - -#[tokio::test] -async fn ensure_installed_downloads_and_extracts_zip_runtime() { - let server = MockServer::start().await; - let runtime_version = "2.5.6"; - let platform = - ArtifactRuntimePlatform::detect_current().unwrap_or_else(|error| panic!("{error}")); - let archive_name = format!( - "artifact-runtime-v{runtime_version}-{}.zip", - platform.as_str() - ); - let archive_bytes = build_zip_archive(runtime_version); - let archive_sha = format!("{:x}", Sha256::digest(&archive_bytes)); - let manifest = ReleaseManifest { - schema_version: 1, - runtime_version: runtime_version.to_string(), - release_tag: format!("artifact-runtime-v{runtime_version}"), - node_version: None, - platforms: BTreeMap::from([( - platform.as_str().to_string(), - PackageReleaseArchive { - archive: archive_name.clone(), - sha256: archive_sha, - format: ArchiveFormat::Zip, - size_bytes: Some(archive_bytes.len() as u64), - }, - )]), - }; - Mock::given(method("GET")) - .and(path(format!( - "/artifact-runtime-v{runtime_version}/artifact-runtime-v{runtime_version}-manifest.json" - ))) - .respond_with(ResponseTemplate::new(200).set_body_json(&manifest)) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path(format!( - "/artifact-runtime-v{runtime_version}/{archive_name}" - ))) - .respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes)) - .mount(&server) - .await; - - let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}")); - let locator = ArtifactRuntimeReleaseLocator::new( - url::Url::parse(&format!("{}/", server.uri())).unwrap_or_else(|error| panic!("{error}")), - runtime_version, - ); - let manager = ArtifactRuntimeManager::new(ArtifactRuntimeManagerConfig::new( - codex_home.path().to_path_buf(), - locator, - )); - - let runtime = manager - .ensure_installed() - .await - .unwrap_or_else(|error| panic!("{error}")); - - assert_eq!(runtime.runtime_version(), runtime_version); - assert_eq!(runtime.platform(), platform); - assert!( - runtime - .build_js_path() - .ends_with(Path::new("dist/artifact_tool.mjs")) - ); -} - -#[test] -fn load_cached_runtime_requires_package_export() { - let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}")); - let runtime_version = "2.5.6"; - let platform = - ArtifactRuntimePlatform::detect_current().unwrap_or_else(|error| panic!("{error}")); - let install_dir = codex_home - .path() - .join(DEFAULT_CACHE_ROOT_RELATIVE) - .join(runtime_version) - .join(platform.as_str()); - write_installed_runtime(&install_dir, runtime_version); - fs::write( - install_dir.join("package.json"), - serde_json::json!({ - "name": "@oai/artifact-tool", - "version": runtime_version, - "type": "module", - }) - .to_string(), - ) - .unwrap_or_else(|error| panic!("{error}")); - - let error = load_cached_runtime( - &codex_home.path().join(DEFAULT_CACHE_ROOT_RELATIVE), - runtime_version, - ) - .unwrap_err(); - - assert_eq!( - error.to_string(), - format!( - "invalid package metadata at {}", - install_dir.join("package.json").display() - ) - ); -} - -#[tokio::test] -async fn ensure_installed_downloads_and_extracts_tar_gz_runtime() { - let server = MockServer::start().await; - let runtime_version = "2.5.6"; - let platform = - ArtifactRuntimePlatform::detect_current().unwrap_or_else(|error| panic!("{error}")); - let archive_name = format!( - "artifact-runtime-v{runtime_version}-{}.tar.gz", - platform.as_str() - ); - let archive_bytes = build_tar_gz_archive(runtime_version); - let archive_sha = format!("{:x}", Sha256::digest(&archive_bytes)); - let manifest = ReleaseManifest { - schema_version: 1, - runtime_version: runtime_version.to_string(), - release_tag: format!("artifact-runtime-v{runtime_version}"), - node_version: None, - platforms: BTreeMap::from([( - platform.as_str().to_string(), - PackageReleaseArchive { - archive: archive_name.clone(), - sha256: archive_sha, - format: ArchiveFormat::TarGz, - size_bytes: Some(archive_bytes.len() as u64), - }, - )]), - }; - Mock::given(method("GET")) - .and(path(format!( - "/artifact-runtime-v{runtime_version}/artifact-runtime-v{runtime_version}-manifest.json" - ))) - .respond_with(ResponseTemplate::new(200).set_body_json(&manifest)) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path(format!( - "/artifact-runtime-v{runtime_version}/{archive_name}" - ))) - .respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes)) - .mount(&server) - .await; - - let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}")); - let locator = ArtifactRuntimeReleaseLocator::new( - url::Url::parse(&format!("{}/", server.uri())).unwrap_or_else(|error| panic!("{error}")), - runtime_version, - ); - let manager = ArtifactRuntimeManager::new(ArtifactRuntimeManagerConfig::new( - codex_home.path().to_path_buf(), - locator, - )); - - let runtime = manager - .ensure_installed() - .await - .unwrap_or_else(|error| panic!("{error}")); - - assert_eq!(runtime.runtime_version(), runtime_version); - assert_eq!(runtime.platform(), platform); - assert!( - runtime - .build_js_path() - .ends_with(Path::new("dist/artifact_tool.mjs")) - ); -} - -#[test] -fn load_cached_runtime_uses_custom_cache_root() { - let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}")); - let runtime_version = "2.5.6"; - let custom_cache_root = codex_home.path().join("runtime-cache"); - let platform = - ArtifactRuntimePlatform::detect_current().unwrap_or_else(|error| panic!("{error}")); - let install_dir = custom_cache_root - .join(runtime_version) - .join(platform.as_str()); - write_installed_runtime(&install_dir, runtime_version); - - let config = ArtifactRuntimeManagerConfig::with_default_release( - codex_home.path().to_path_buf(), - runtime_version, - ) - .with_cache_root(custom_cache_root); - - let runtime = load_cached_runtime(&config.cache_root(), runtime_version) - .unwrap_or_else(|error| panic!("{error}")); - - assert_eq!(runtime.runtime_version(), runtime_version); - assert_eq!(runtime.platform(), platform); -} - -#[tokio::test] -#[cfg(unix)] -async fn artifacts_client_execute_build_writes_wrapped_script_and_env() { - let temp = TempDir::new().unwrap_or_else(|error| panic!("{error}")); - let runtime_root = temp.path().join("runtime"); - write_installed_runtime(&runtime_root, "2.5.6"); - let runtime = crate::InstalledArtifactRuntime::load( - runtime_root, - ArtifactRuntimePlatform::detect_current().unwrap_or_else(|error| panic!("{error}")), - ) - .unwrap_or_else(|error| panic!("{error}")); - let client = ArtifactsClient::from_installed_runtime(runtime); - - let output = client - .execute_build(ArtifactBuildRequest { - source: concat!( - "console.log(typeof artifacts);\n", - "console.log(typeof codexArtifacts);\n", - "console.log(artifactTool.ok);\n", - "console.log(ok);\n", - "console.error('stderr-ok');\n", - "console.log('stdout-ok');\n" - ) - .to_string(), - cwd: temp.path().to_path_buf(), - timeout: Some(Duration::from_secs(5)), - env: BTreeMap::new(), - }) - .await - .unwrap_or_else(|error| panic!("{error}")); - - assert_success(&output); - assert_eq!(output.stderr.trim(), "stderr-ok"); - assert_eq!( - output.stdout.lines().collect::>(), - vec!["undefined", "undefined", "true", "true", "stdout-ok"] - ); -} - -fn assert_success(output: &ArtifactCommandOutput) { - assert!(output.success()); - assert_eq!(output.exit_code, Some(0)); -} - -fn write_installed_runtime(install_dir: &Path, runtime_version: &str) { - fs::create_dir_all(install_dir.join("dist")).unwrap_or_else(|error| panic!("{error}")); - fs::write( - install_dir.join("package.json"), - serde_json::json!({ - "name": "@oai/artifact-tool", - "version": runtime_version, - "type": "module", - "exports": { - ".": "./dist/artifact_tool.mjs", - } - }) - .to_string(), - ) - .unwrap_or_else(|error| panic!("{error}")); - fs::write( - install_dir.join("dist/artifact_tool.mjs"), - "export const ok = true;\n", - ) - .unwrap_or_else(|error| panic!("{error}")); -} - -fn build_zip_archive(runtime_version: &str) -> Vec { - let mut bytes = Cursor::new(Vec::new()); - { - let mut zip = ZipWriter::new(&mut bytes); - let options = SimpleFileOptions::default(); - let package_json = serde_json::json!({ - "name": "@oai/artifact-tool", - "version": runtime_version, - "type": "module", - "exports": { - ".": "./dist/artifact_tool.mjs", - } - }) - .to_string() - .into_bytes(); - zip.start_file("artifact-runtime/package.json", options) - .unwrap_or_else(|error| panic!("{error}")); - zip.write_all(&package_json) - .unwrap_or_else(|error| panic!("{error}")); - zip.start_file("artifact-runtime/dist/artifact_tool.mjs", options) - .unwrap_or_else(|error| panic!("{error}")); - zip.write_all(b"export const ok = true;\n") - .unwrap_or_else(|error| panic!("{error}")); - zip.finish().unwrap_or_else(|error| panic!("{error}")); - } - bytes.into_inner() -} - -fn build_tar_gz_archive(runtime_version: &str) -> Vec { - let mut bytes = Vec::new(); - { - let encoder = GzEncoder::new(&mut bytes, Compression::default()); - let mut archive = TarBuilder::new(encoder); - - let package_json = serde_json::json!({ - "name": "@oai/artifact-tool", - "version": runtime_version, - "type": "module", - "exports": { - ".": "./dist/artifact_tool.mjs", - } - }) - .to_string() - .into_bytes(); - let mut package_header = tar::Header::new_gnu(); - package_header.set_mode(0o644); - package_header.set_size(package_json.len() as u64); - package_header.set_cksum(); - archive - .append_data( - &mut package_header, - "package/package.json", - package_json.as_slice(), - ) - .unwrap_or_else(|error| panic!("{error}")); - - let build_js = b"export const ok = true;\n"; - let mut build_header = tar::Header::new_gnu(); - build_header.set_mode(0o644); - build_header.set_size(build_js.len() as u64); - build_header.set_cksum(); - archive - .append_data( - &mut build_header, - "package/dist/artifact_tool.mjs", - &build_js[..], - ) - .unwrap_or_else(|error| panic!("{error}")); - - archive.finish().unwrap_or_else(|error| panic!("{error}")); - } - bytes -} diff --git a/codex-rs/backend-client/Cargo.toml b/codex-rs/backend-client/Cargo.toml index 8279dba630..1707d45b1b 100644 --- a/codex-rs/backend-client/Cargo.toml +++ b/codex-rs/backend-client/Cargo.toml @@ -8,6 +8,9 @@ publish = false [lib] path = "src/lib.rs" +[lints] +workspace = true + [dependencies] anyhow = "1" serde = { version = "1", features = ["derive"] } @@ -15,8 +18,8 @@ serde_json = "1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } codex-backend-openapi-models = { path = "../codex-backend-openapi-models" } codex-client = { workspace = true } +codex-login = { workspace = true } codex-protocol = { workspace = true } -codex-core = { workspace = true } [dev-dependencies] pretty_assertions = "1" diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index e7fe145987..6049c1697b 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -5,8 +5,8 @@ use crate::types::RateLimitStatusPayload; use crate::types::TurnAttemptsSiblingTurnsResponse; use anyhow::Result; use codex_client::build_reqwest_client_with_custom_ca; -use codex_core::auth::CodexAuth; -use codex_core::default_client::get_codex_user_agent; +use codex_login::CodexAuth; +use codex_login::default_client::get_codex_user_agent; use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::protocol::CreditsSnapshot; use codex_protocol::protocol::RateLimitSnapshot; @@ -474,7 +474,13 @@ impl Client { crate::types::PlanType::Plus => AccountPlanType::Plus, crate::types::PlanType::Pro => AccountPlanType::Pro, crate::types::PlanType::Team => AccountPlanType::Team, + crate::types::PlanType::SelfServeBusinessUsageBased => { + AccountPlanType::SelfServeBusinessUsageBased + } crate::types::PlanType::Business => AccountPlanType::Business, + crate::types::PlanType::EnterpriseCbpUsageBased => { + AccountPlanType::EnterpriseCbpUsageBased + } crate::types::PlanType::Enterprise => AccountPlanType::Enterprise, crate::types::PlanType::Edu | crate::types::PlanType::Education => AccountPlanType::Edu, crate::types::PlanType::Guest @@ -497,8 +503,21 @@ impl Client { #[cfg(test)] mod tests { use super::*; + use codex_backend_openapi_models::models::AdditionalRateLimitDetails; use pretty_assertions::assert_eq; + #[test] + fn map_plan_type_supports_usage_based_business_variants() { + assert_eq!( + Client::map_plan_type(crate::types::PlanType::SelfServeBusinessUsageBased), + AccountPlanType::SelfServeBusinessUsageBased + ); + assert_eq!( + Client::map_plan_type(crate::types::PlanType::EnterpriseCbpUsageBased), + AccountPlanType::EnterpriseCbpUsageBased + ); + } + #[test] fn usage_payload_maps_primary_and_additional_rate_limits() { let payload = RateLimitStatusPayload { @@ -518,7 +537,7 @@ mod tests { }))), ..Default::default() }))), - additional_rate_limits: Some(Some(vec![crate::types::AdditionalRateLimitDetails { + additional_rate_limits: Some(Some(vec![AdditionalRateLimitDetails { limit_name: "codex_other".to_string(), metered_feature: "codex_other".to_string(), rate_limit: Some(Some(Box::new(crate::types::RateLimitStatusDetails { @@ -578,7 +597,7 @@ mod tests { let payload = RateLimitStatusPayload { plan_type: crate::types::PlanType::Plus, rate_limit: None, - additional_rate_limits: Some(Some(vec![crate::types::AdditionalRateLimitDetails { + additional_rate_limits: Some(Some(vec![AdditionalRateLimitDetails { limit_name: "codex_other".to_string(), metered_feature: "codex_other".to_string(), rate_limit: None, diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index a3b2f37608..397c2a2cd4 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -1,5 +1,5 @@ mod client; -pub mod types; +pub(crate) mod types; pub use client::Client; pub use client::RequestError; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 2559b2b39e..22e0984cda 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -1,4 +1,3 @@ -pub use codex_backend_openapi_models::models::AdditionalRateLimitDetails; pub use codex_backend_openapi_models::models::ConfigFileResponse; pub use codex_backend_openapi_models::models::CreditStatusDetails; pub use codex_backend_openapi_models::models::PaginatedListTaskListItem; @@ -309,7 +308,7 @@ where D: Deserializer<'de>, T: Deserialize<'de>, { - Option::>::deserialize(deserializer).map(|opt| opt.unwrap_or_default()) + Option::>::deserialize(deserializer).map(Option::unwrap_or_default) } #[derive(Clone, Debug, Deserialize)] diff --git a/codex-rs/chatgpt/Cargo.toml b/codex-rs/chatgpt/Cargo.toml index cd14a67009..381b4fc873 100644 --- a/codex-rs/chatgpt/Cargo.toml +++ b/codex-rs/chatgpt/Cargo.toml @@ -11,14 +11,16 @@ workspace = true anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } codex-connectors = { workspace = true } +codex-config = { workspace = true } codex-core = { workspace = true } -codex-utils-cli = { workspace = true } -codex-utils-cargo-bin = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -tokio = { workspace = true, features = ["full"] } codex-git-utils = { workspace = true } +codex-login = { workspace = true } +codex-utils-cli = { workspace = true } +serde = { workspace = true, features = ["derive"] } +tokio = { workspace = true, features = ["full"] } [dev-dependencies] +codex-utils-cargo-bin = { workspace = true } pretty_assertions = { workspace = true } +serde_json = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/chatgpt/src/chatgpt_client.rs b/codex-rs/chatgpt/src/chatgpt_client.rs index 5d02bdac7f..fa3a63dadb 100644 --- a/codex-rs/chatgpt/src/chatgpt_client.rs +++ b/codex-rs/chatgpt/src/chatgpt_client.rs @@ -1,5 +1,5 @@ use codex_core::config::Config; -use codex_core::default_client::create_client; +use codex_login::default_client::create_client; use crate::chatgpt_token::get_chatgpt_token_data; use crate::chatgpt_token::init_chatgpt_token_from_auth; diff --git a/codex-rs/chatgpt/src/chatgpt_token.rs b/codex-rs/chatgpt/src/chatgpt_token.rs index 93aaef7ed2..d20a7e57c4 100644 --- a/codex-rs/chatgpt/src/chatgpt_token.rs +++ b/codex-rs/chatgpt/src/chatgpt_token.rs @@ -1,11 +1,10 @@ -use codex_core::AuthManager; +use codex_config::types::AuthCredentialsStoreMode; +use codex_login::AuthManager; +use codex_login::token_data::TokenData; use std::path::Path; use std::sync::LazyLock; use std::sync::RwLock; -use codex_core::auth::AuthCredentialsStoreMode; -use codex_core::token_data::TokenData; - static CHATGPT_TOKEN: LazyLock>> = LazyLock::new(|| RwLock::new(None)); pub fn get_chatgpt_token_data() -> Option { diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 4dcf8886c7..1ea293f974 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -1,6 +1,7 @@ -use codex_core::AuthManager; use codex_core::config::Config; -use codex_core::token_data::TokenData; +use codex_login::AuthManager; +use codex_login::CodexAuth; +use codex_login::token_data::TokenData; use std::collections::HashSet; use std::time::Duration; @@ -32,7 +33,10 @@ async fn apps_enabled(config: &Config) -> bool { /*enable_codex_api_key_env*/ false, config.cli_auth_credentials_store_mode, ); - config.features.apps_enabled(Some(&auth_manager)).await + let auth = auth_manager.auth().await; + config + .features + .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth)) } pub async fn list_connectors(config: &Config) -> anyhow::Result> { if !apps_enabled(config).await { @@ -245,9 +249,9 @@ mod tests { let merged = merge_connectors_with_accessible( vec![app("alpha")], vec![app("alpha"), app("beta")], - true, + /*all_connectors_loaded*/ true, ); - assert_eq!(merged, vec![merged_app("alpha", true)]); + assert_eq!(merged, vec![merged_app("alpha", /*is_accessible*/ true)]); } #[test] @@ -255,11 +259,14 @@ mod tests { let merged = merge_connectors_with_accessible( vec![app("alpha")], vec![app("alpha"), app("beta")], - false, + /*all_connectors_loaded*/ false, ); assert_eq!( merged, - vec![merged_app("alpha", true), merged_app("beta", true)] + vec![ + merged_app("alpha", /*is_accessible*/ true), + merged_app("beta", /*is_accessible*/ true) + ] ); } @@ -272,7 +279,10 @@ mod tests { AppConnectorId("gmail".to_string()), ], ); - assert_eq!(connectors, vec![app("alpha"), merged_app("gmail", false)]); + assert_eq!( + connectors, + vec![app("alpha"), merged_app("gmail", /*is_accessible*/ false)] + ); } #[test] diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 7e703efe10..8ba27b3339 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -32,6 +32,7 @@ codex-exec = { workspace = true } codex-execpolicy = { workspace = true } codex-features = { workspace = true } codex-login = { workspace = true } +codex-mcp = { workspace = true } codex-mcp-server = { workspace = true } codex-protocol = { workspace = true } codex-responses-api-proxy = { workspace = true } @@ -41,7 +42,7 @@ codex-state = { workspace = true } codex-stdio-to-uds = { workspace = true } codex-terminal-detection = { workspace = true } codex-tui = { workspace = true } -codex-tui-app-server = { workspace = true } +codex-utils-path = { workspace = true } libc = { workspace = true } owo-colors = { workspace = true } regex-lite = { workspace = true } diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index d519bd5f67..adb205eefa 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -18,7 +18,7 @@ use codex_protocol::config_types::SandboxMode; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_policies; #[cfg(target_os = "macos")] -use codex_sandboxing::seatbelt::create_seatbelt_command_args_for_policies_with_extensions; +use codex_sandboxing::seatbelt::create_seatbelt_command_args_for_policies; use codex_utils_cli::CliConfigOverrides; use tokio::process::Child; use tokio::process::Command as TokioCommand; @@ -164,14 +164,17 @@ async fn run_command_under_sandbox( let res = tokio::task::spawn_blocking(move || { if use_elevated { run_windows_sandbox_capture_elevated( - policy_str.as_str(), - &sandbox_cwd, - base_dir.as_path(), - command_vec, - &cwd_clone, - env_map, - /*timeout_ms*/ None, - config.permissions.windows_sandbox_private_desktop, + codex_windows_sandbox::ElevatedSandboxCaptureRequest { + policy_json_or_preset: policy_str.as_str(), + sandbox_policy_cwd: &sandbox_cwd, + codex_home: base_dir.as_path(), + command: command_vec, + cwd: &cwd_clone, + env_map, + timeout_ms: None, + use_private_desktop: config.permissions.windows_sandbox_private_desktop, + proxy_enforced: false, + }, ) } else { run_windows_sandbox_capture( @@ -246,14 +249,13 @@ async fn run_command_under_sandbox( let mut child = match sandbox_type { #[cfg(target_os = "macos")] SandboxType::Seatbelt => { - let args = create_seatbelt_command_args_for_policies_with_extensions( + let args = create_seatbelt_command_args_for_policies( command, &config.permissions.file_system_sandbox_policy, config.permissions.network_sandbox_policy, sandbox_policy_cwd.as_path(), /*enforce_managed_network*/ false, network.as_ref(), - /*extensions*/ None, ); let network_policy = config.permissions.network_sandbox_policy; spawn_debug_sandbox_child( @@ -496,7 +498,7 @@ mod tests { let legacy_config = build_debug_sandbox_config( Vec::new(), ConfigOverrides { - sandbox_mode: Some(create_sandbox_mode(false)), + sandbox_mode: Some(create_sandbox_mode(/*full_auto*/ false)), ..Default::default() }, Some(codex_home_path.clone()), @@ -505,8 +507,8 @@ mod tests { let config = load_debug_sandbox_config_with_codex_home( Vec::new(), - None, - false, + /*codex_linux_sandbox_exe*/ None, + /*full_auto*/ false, Some(codex_home_path), ) .await?; @@ -539,8 +541,8 @@ mod tests { let err = load_debug_sandbox_config_with_codex_home( Vec::new(), - None, - true, + /*codex_linux_sandbox_exe*/ None, + /*full_auto*/ true, Some(codex_home.path().to_path_buf()), ) .await diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index b6174efa3a..848391293c 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -1,10 +1,21 @@ -pub mod debug_sandbox; +pub(crate) mod debug_sandbox; mod exit_status; -pub mod login; +pub(crate) mod login; use clap::Parser; 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_api_key_from_stdin; +pub use login::run_login_status; +pub use login::run_login_with_api_key; +pub use login::run_login_with_chatgpt; +pub use login::run_login_with_device_code; +pub use login::run_login_with_device_code_fallback_to_browser; +pub use login::run_logout; + #[derive(Debug, Parser)] pub struct SeatbeltCommand { /// Convenience alias for low-friction sandboxed automatic execution (network-disabled sandbox that can write to cwd and TMPDIR) diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index d0cc1a3a1d..9fa7dc4508 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -7,14 +7,14 @@ //! into a one-shot CLI command while still producing a durable `codex-login.log` artifact that //! support can request from users. -use codex_core::CodexAuth; -use codex_core::auth::AuthCredentialsStoreMode; -use codex_core::auth::AuthMode; -use codex_core::auth::CLIENT_ID; -use codex_core::auth::login_with_api_key; -use codex_core::auth::logout; +use codex_app_server_protocol::AuthMode; +use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::Config; +use codex_login::CLIENT_ID; +use codex_login::CodexAuth; use codex_login::ServerOptions; +use codex_login::login_with_api_key; +use codex_login::logout; use codex_login::run_device_code_login; use codex_login::run_login_server; use codex_protocol::config_types::ForcedLoginMethod; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 5e0d405eb3..24dd558ea7 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -10,12 +10,12 @@ use codex_chatgpt::apply_command::run_apply_command; use codex_cli::LandlockCommand; use codex_cli::SeatbeltCommand; use codex_cli::WindowsCommand; -use codex_cli::login::read_api_key_from_stdin; -use codex_cli::login::run_login_status; -use codex_cli::login::run_login_with_api_key; -use codex_cli::login::run_login_with_chatgpt; -use codex_cli::login::run_login_with_device_code; -use codex_cli::login::run_logout; +use codex_cli::read_api_key_from_stdin; +use codex_cli::run_login_status; +use codex_cli::run_login_with_api_key; +use codex_cli::run_login_with_chatgpt; +use codex_cli::run_login_with_device_code; +use codex_cli::run_logout; use codex_cloud_tasks::Cli as CloudTasksCli; use codex_exec::Cli as ExecCli; use codex_exec::Command as ExecCommand; @@ -27,7 +27,7 @@ use codex_state::state_db_path; use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; use codex_tui::ExitReason; -use codex_tui::update_action::UpdateAction; +use codex_tui::UpdateAction; use codex_utils_cli::CliConfigOverrides; use owo_colors::OwoColorize; use std::io::IsTerminal; @@ -51,6 +51,8 @@ use codex_core::config::find_codex_home; use codex_features::FEATURES; use codex_features::Stage; use codex_features::is_known_feature_key; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::user_input::UserInput; use codex_terminal_detection::TerminalName; /// Codex CLI @@ -170,6 +172,9 @@ enum DebugSubcommand { /// Tooling: helps debug the app server. AppServer(DebugAppServerCommand), + /// Render the model-visible prompt input list as JSON. + PromptInput(DebugPromptInputCommand), + /// Internal: reset local memory state for a fresh start. #[clap(hide = true)] ClearMemories, @@ -193,6 +198,17 @@ struct DebugAppServerSendMessageV2Command { user_message: String, } +#[derive(Debug, Parser)] +struct DebugPromptInputCommand { + /// Optional user prompt to append after session context. + #[arg(value_name = "PROMPT")] + prompt: Option, + + /// Optional image(s) to attach to the user prompt. + #[arg(long = "image", short = 'i', value_name = "FILE", value_delimiter = ',', num_args = 1..)] + images: Vec, +} + #[derive(Debug, Parser)] struct ResumeCommand { /// Conversation/session id (UUID) or thread name. UUIDs take precedence if it parses. @@ -287,6 +303,8 @@ struct LoginCommand { #[arg( long = "api-key", + num_args = 0..=1, + default_missing_value = "", value_name = "API_KEY", help = "(deprecated) Previously accepted the API key directly; now exits with guidance to use --with-api-key", hide = true @@ -328,7 +346,7 @@ struct AppServerCommand { subcommand: Option, /// Transport endpoint URL. Supported values: `stdio://` (default), - /// `ws://IP:PORT`. + /// `ws://IP:PORT`, `off`. #[arg( long = "listen", value_name = "URL", @@ -420,15 +438,14 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec, + + /// Name of the environment variable containing the bearer token to send to + /// a remote app server websocket. + #[arg(long = "remote-auth-token-env", value_name = "ENV_VAR")] + remote_auth_token_env: Option, } impl FeatureToggles { @@ -607,6 +629,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { let toggle_overrides = feature_toggles.to_overrides()?; root_config_overrides.raw_overrides.extend(toggle_overrides); let root_remote = remote.remote; + let root_remote_auth_token_env = remote.remote_auth_token_env; match subcommand { None => { @@ -614,12 +637,21 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { &mut interactive.config_overrides, root_config_overrides.clone(), ); - let exit_info = - run_interactive_tui(interactive, root_remote.clone(), arg0_paths.clone()).await?; + let exit_info = run_interactive_tui( + interactive, + root_remote.clone(), + root_remote_auth_token_env.clone(), + arg0_paths.clone(), + ) + .await?; handle_app_exit(exit_info)?; } Some(Subcommand::Exec(mut exec_cli)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "exec")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "exec", + )?; prepend_config_flags( &mut exec_cli.config_overrides, root_config_overrides.clone(), @@ -627,7 +659,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { codex_exec::run_main(exec_cli, arg0_paths.clone()).await?; } Some(Subcommand::Review(review_args)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "review")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "review", + )?; let mut exec_cli = ExecCli::try_parse_from(["codex", "exec"])?; exec_cli.command = Some(ExecCommand::Review(review_args)); prepend_config_flags( @@ -637,11 +673,19 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { codex_exec::run_main(exec_cli, arg0_paths.clone()).await?; } Some(Subcommand::McpServer) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "mcp-server")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "mcp-server", + )?; codex_mcp_server::run_main(arg0_paths.clone(), root_config_overrides).await?; } Some(Subcommand::Mcp(mut mcp_cli)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "mcp")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "mcp", + )?; // Propagate any root-level config overrides (e.g. `-c key=value`). prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone()); mcp_cli.run().await?; @@ -653,9 +697,13 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { analytics_default_enabled, auth, } = app_server_cli; + reject_remote_mode_for_app_server_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + subcommand.as_ref(), + )?; match subcommand { None => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "app-server")?; let transport = listen; let auth = auth.try_into_settings()?; codex_app_server::run_main_with_transport( @@ -670,10 +718,6 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { .await?; } Some(AppServerSubcommand::GenerateTs(gen_cli)) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - "app-server generate-ts", - )?; let options = codex_app_server_protocol::GenerateTsOptions { experimental_api: gen_cli.experimental, ..Default::default() @@ -685,10 +729,6 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { )?; } Some(AppServerSubcommand::GenerateJsonSchema(gen_cli)) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - "app-server generate-json-schema", - )?; codex_app_server_protocol::generate_json_with_experimental( &gen_cli.out_dir, gen_cli.experimental, @@ -701,7 +741,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } #[cfg(target_os = "macos")] Some(Subcommand::App(app_cli)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "app")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "app", + )?; app_cmd::run_app(app_cli).await?; } Some(Subcommand::Resume(ResumeCommand { @@ -724,6 +768,9 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { let exit_info = run_interactive_tui( interactive, remote.remote.or(root_remote.clone()), + remote + .remote_auth_token_env + .or(root_remote_auth_token_env.clone()), arg0_paths.clone(), ) .await?; @@ -747,13 +794,20 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { let exit_info = run_interactive_tui( interactive, remote.remote.or(root_remote.clone()), + remote + .remote_auth_token_env + .or(root_remote_auth_token_env.clone()), arg0_paths.clone(), ) .await?; handle_app_exit(exit_info)?; } Some(Subcommand::Login(mut login_cli)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "login")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "login", + )?; prepend_config_flags( &mut login_cli.config_overrides, root_config_overrides.clone(), @@ -785,7 +839,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } } Some(Subcommand::Logout(mut logout_cli)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "logout")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "logout", + )?; prepend_config_flags( &mut logout_cli.config_overrides, root_config_overrides.clone(), @@ -793,11 +851,19 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { run_logout(logout_cli.config_overrides).await; } Some(Subcommand::Completion(completion_cli)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "completion")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "completion", + )?; print_completion(completion_cli); } Some(Subcommand::Cloud(mut cloud_cli)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "cloud")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "cloud", + )?; prepend_config_flags( &mut cloud_cli.config_overrides, root_config_overrides.clone(), @@ -807,36 +873,48 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } Some(Subcommand::Sandbox(sandbox_args)) => match sandbox_args.cmd { SandboxCommand::Macos(mut seatbelt_cli) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "sandbox macos")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "sandbox macos", + )?; prepend_config_flags( &mut seatbelt_cli.config_overrides, root_config_overrides.clone(), ); - codex_cli::debug_sandbox::run_command_under_seatbelt( + codex_cli::run_command_under_seatbelt( seatbelt_cli, arg0_paths.codex_linux_sandbox_exe.clone(), ) .await?; } SandboxCommand::Linux(mut landlock_cli) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "sandbox linux")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "sandbox linux", + )?; prepend_config_flags( &mut landlock_cli.config_overrides, root_config_overrides.clone(), ); - codex_cli::debug_sandbox::run_command_under_landlock( + codex_cli::run_command_under_landlock( landlock_cli, arg0_paths.codex_linux_sandbox_exe.clone(), ) .await?; } SandboxCommand::Windows(mut windows_cli) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "sandbox windows")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "sandbox windows", + )?; prepend_config_flags( &mut windows_cli.config_overrides, root_config_overrides.clone(), ); - codex_cli::debug_sandbox::run_command_under_windows( + codex_cli::run_command_under_windows( windows_cli, arg0_paths.codex_linux_sandbox_exe.clone(), ) @@ -845,22 +923,52 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { }, Some(Subcommand::Debug(DebugCommand { subcommand })) => match subcommand { DebugSubcommand::AppServer(cmd) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "debug app-server")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "debug app-server", + )?; run_debug_app_server_command(cmd).await?; } + DebugSubcommand::PromptInput(cmd) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "debug prompt-input", + )?; + run_debug_prompt_input_command( + cmd, + root_config_overrides, + interactive, + arg0_paths.clone(), + ) + .await?; + } DebugSubcommand::ClearMemories => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "debug clear-memories")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "debug clear-memories", + )?; run_debug_clear_memories_command(&root_config_overrides, &interactive).await?; } }, Some(Subcommand::Execpolicy(ExecpolicyCommand { sub })) => match sub { ExecpolicySubcommand::Check(cmd) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "execpolicy check")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "execpolicy check", + )?; run_execpolicycheck(cmd)? } }, Some(Subcommand::Apply(mut apply_cli)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "apply")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "apply", + )?; prepend_config_flags( &mut apply_cli.config_overrides, root_config_overrides.clone(), @@ -868,19 +976,31 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { run_apply_command(apply_cli, /*cwd*/ None).await?; } Some(Subcommand::ResponsesApiProxy(args)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "responses-api-proxy")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "responses-api-proxy", + )?; tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args)) .await??; } Some(Subcommand::StdioToUds(cmd)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "stdio-to-uds")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "stdio-to-uds", + )?; let socket_path = cmd.socket_path; tokio::task::spawn_blocking(move || codex_stdio_to_uds::run(socket_path.as_path())) .await??; } Some(Subcommand::Features(FeaturesCli { sub })) => match sub { FeaturesSubcommand::List => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "features list")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "features list", + )?; // Respect root-level `-c` overrides plus top-level flags like `--profile`. let mut cli_kv_overrides = root_config_overrides .parse_overrides() @@ -923,11 +1043,19 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } } FeaturesSubcommand::Enable(FeatureSetArgs { feature }) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "features enable")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "features enable", + )?; enable_feature_in_config(&interactive, &feature).await?; } FeaturesSubcommand::Disable(FeatureSetArgs { feature }) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "features disable")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "features disable", + )?; disable_feature_in_config(&interactive, &feature).await?; } }, @@ -984,6 +1112,72 @@ fn maybe_print_under_development_feature_warning( ); } +async fn run_debug_prompt_input_command( + cmd: DebugPromptInputCommand, + root_config_overrides: CliConfigOverrides, + interactive: TuiCli, + arg0_paths: Arg0DispatchPaths, +) -> anyhow::Result<()> { + let mut cli_kv_overrides = root_config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + if interactive.web_search { + cli_kv_overrides.push(( + "web_search".to_string(), + toml::Value::String("live".to_string()), + )); + } + + let approval_policy = if interactive.full_auto { + Some(AskForApproval::OnRequest) + } else if interactive.dangerously_bypass_approvals_and_sandbox { + Some(AskForApproval::Never) + } else { + interactive.approval_policy.map(Into::into) + }; + let sandbox_mode = if interactive.full_auto { + Some(codex_protocol::config_types::SandboxMode::WorkspaceWrite) + } else if interactive.dangerously_bypass_approvals_and_sandbox { + Some(codex_protocol::config_types::SandboxMode::DangerFullAccess) + } else { + interactive.sandbox_mode.map(Into::into) + }; + let overrides = ConfigOverrides { + model: interactive.model, + config_profile: interactive.config_profile, + approval_policy, + sandbox_mode, + cwd: interactive.cwd, + codex_self_exe: arg0_paths.codex_self_exe, + codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe, + main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe, + show_raw_agent_reasoning: interactive.oss.then_some(true), + ephemeral: Some(true), + additional_writable_roots: interactive.add_dir, + ..Default::default() + }; + let config = + Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await?; + + let mut input = interactive + .images + .into_iter() + .chain(cmd.images) + .map(|path| UserInput::LocalImage { path }) + .collect::>(); + if let Some(prompt) = cmd.prompt.or(interactive.prompt) { + input.push(UserInput::Text { + text: prompt.replace("\r\n", "\n").replace('\r', "\n"), + text_elements: Vec::new(), + }); + } + + let prompt_input = codex_core::build_prompt_input(config, input).await?; + println!("{}", serde_json::to_string_pretty(&prompt_input)?); + + Ok(()) +} + async fn run_debug_clear_memories_command( root_config_overrides: &CliConfigOverrides, interactive: &TuiCli, @@ -1046,18 +1240,64 @@ fn prepend_config_flags( .splice(0..0, cli_config_overrides.raw_overrides); } -fn reject_remote_mode_for_subcommand(remote: Option<&str>, subcommand: &str) -> anyhow::Result<()> { +fn reject_remote_mode_for_subcommand( + remote: Option<&str>, + remote_auth_token_env: Option<&str>, + subcommand: &str, +) -> anyhow::Result<()> { if let Some(remote) = remote { anyhow::bail!( "`--remote {remote}` is only supported for interactive TUI commands, not `codex {subcommand}`" ); } + if remote_auth_token_env.is_some() { + anyhow::bail!( + "`--remote-auth-token-env` is only supported for interactive TUI commands, not `codex {subcommand}`" + ); + } Ok(()) } +fn reject_remote_mode_for_app_server_subcommand( + remote: Option<&str>, + remote_auth_token_env: Option<&str>, + subcommand: Option<&AppServerSubcommand>, +) -> anyhow::Result<()> { + let subcommand_name = match subcommand { + None => "app-server", + Some(AppServerSubcommand::GenerateTs(_)) => "app-server generate-ts", + Some(AppServerSubcommand::GenerateJsonSchema(_)) => "app-server generate-json-schema", + Some(AppServerSubcommand::GenerateInternalJsonSchema(_)) => { + "app-server generate-internal-json-schema" + } + }; + reject_remote_mode_for_subcommand(remote, remote_auth_token_env, subcommand_name) +} + +fn read_remote_auth_token_from_env_var_with( + env_var_name: &str, + get_var: F, +) -> anyhow::Result +where + F: FnOnce(&str) -> Result, +{ + let auth_token = get_var(env_var_name) + .map_err(|_| anyhow::anyhow!("environment variable `{env_var_name}` is not set"))?; + let auth_token = auth_token.trim().to_string(); + if auth_token.is_empty() { + anyhow::bail!("environment variable `{env_var_name}` is empty"); + } + Ok(auth_token) +} + +fn read_remote_auth_token_from_env_var(env_var_name: &str) -> anyhow::Result { + read_remote_auth_token_from_env_var_with(env_var_name, |name| std::env::var(name)) +} + async fn run_interactive_tui( mut interactive: TuiCli, remote: Option, + remote_auth_token_env: Option, arg0_paths: Arg0DispatchPaths, ) -> std::io::Result { if let Some(prompt) = interactive.prompt.take() { @@ -1083,93 +1323,29 @@ async fn run_interactive_tui( } } - let use_app_server_tui = codex_tui::should_use_app_server_tui(&interactive).await?; let normalized_remote = remote .as_deref() - .map(codex_tui_app_server::normalize_remote_addr) + .map(codex_tui::normalize_remote_addr) .transpose() .map_err(std::io::Error::other)?; - if normalized_remote.is_some() && !use_app_server_tui { + if remote_auth_token_env.is_some() && normalized_remote.is_none() { return Ok(AppExitInfo::fatal( - "`--remote` requires the `tui_app_server` feature flag to be enabled.", + "`--remote-auth-token-env` requires `--remote`.", )); } - if use_app_server_tui { - codex_tui_app_server::run_main( - into_app_server_tui_cli(interactive), - arg0_paths, - codex_core::config_loader::LoaderOverrides::default(), - normalized_remote, - ) - .await - .map(into_legacy_app_exit_info) - } else { - codex_tui::run_main( - interactive, - arg0_paths, - codex_core::config_loader::LoaderOverrides::default(), - ) - .await - } -} - -fn into_app_server_tui_cli(cli: TuiCli) -> codex_tui_app_server::Cli { - codex_tui_app_server::Cli { - prompt: cli.prompt, - images: cli.images, - resume_picker: cli.resume_picker, - resume_last: cli.resume_last, - resume_session_id: cli.resume_session_id, - resume_show_all: cli.resume_show_all, - fork_picker: cli.fork_picker, - fork_last: cli.fork_last, - fork_session_id: cli.fork_session_id, - fork_show_all: cli.fork_show_all, - model: cli.model, - oss: cli.oss, - oss_provider: cli.oss_provider, - config_profile: cli.config_profile, - sandbox_mode: cli.sandbox_mode, - approval_policy: cli.approval_policy, - full_auto: cli.full_auto, - dangerously_bypass_approvals_and_sandbox: cli.dangerously_bypass_approvals_and_sandbox, - cwd: cli.cwd, - web_search: cli.web_search, - add_dir: cli.add_dir, - no_alt_screen: cli.no_alt_screen, - config_overrides: cli.config_overrides, - } -} - -fn into_legacy_update_action( - action: codex_tui_app_server::update_action::UpdateAction, -) -> UpdateAction { - match action { - codex_tui_app_server::update_action::UpdateAction::NpmGlobalLatest => { - UpdateAction::NpmGlobalLatest - } - codex_tui_app_server::update_action::UpdateAction::BunGlobalLatest => { - UpdateAction::BunGlobalLatest - } - codex_tui_app_server::update_action::UpdateAction::BrewUpgrade => UpdateAction::BrewUpgrade, - } -} - -fn into_legacy_exit_reason(reason: codex_tui_app_server::ExitReason) -> ExitReason { - match reason { - codex_tui_app_server::ExitReason::UserRequested => ExitReason::UserRequested, - codex_tui_app_server::ExitReason::Fatal(message) => ExitReason::Fatal(message), - } -} - -fn into_legacy_app_exit_info(exit_info: codex_tui_app_server::AppExitInfo) -> AppExitInfo { - AppExitInfo { - token_usage: exit_info.token_usage, - thread_id: exit_info.thread_id, - thread_name: exit_info.thread_name, - update_action: exit_info.update_action.map(into_legacy_update_action), - exit_reason: into_legacy_exit_reason(exit_info.exit_reason), - } + let remote_auth_token = remote_auth_token_env + .as_deref() + .map(read_remote_auth_token_from_env_var) + .transpose() + .map_err(std::io::Error::other)?; + codex_tui::run_main( + interactive, + arg0_paths, + codex_core::config_loader::LoaderOverrides::default(), + normalized_remote, + remote_auth_token, + ) + .await } fn confirm(prompt: &str) -> std::io::Result { @@ -1408,6 +1584,32 @@ mod tests { app_server } + #[test] + fn debug_prompt_input_parses_prompt_and_images() { + let cli = MultitoolCli::try_parse_from([ + "codex", + "debug", + "prompt-input", + "hello", + "--image", + "/tmp/a.png,/tmp/b.png", + ]) + .expect("parse"); + + let Some(Subcommand::Debug(DebugCommand { + subcommand: DebugSubcommand::PromptInput(cmd), + })) = cli.subcommand + else { + panic!("expected debug prompt-input subcommand"); + }; + + assert_eq!(cmd.prompt.as_deref(), Some("hello")); + assert_eq!( + cmd.images, + vec![PathBuf::from("/tmp/a.png"), PathBuf::from("/tmp/b.png")] + ); + } + fn sample_exit_info(conversation_id: Option<&str>, thread_name: Option<&str>) -> AppExitInfo { let token_usage = TokenUsage { output_tokens: 2, @@ -1434,14 +1636,17 @@ mod tests { update_action: None, exit_reason: ExitReason::UserRequested, }; - let lines = format_exit_messages(exit_info, false); + let lines = format_exit_messages(exit_info, /*color_enabled*/ false); assert!(lines.is_empty()); } #[test] fn format_exit_messages_includes_resume_hint_without_color() { - let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000"), None); - let lines = format_exit_messages(exit_info, false); + let exit_info = sample_exit_info( + Some("123e4567-e89b-12d3-a456-426614174000"), + /*thread_name*/ None, + ); + let lines = format_exit_messages(exit_info, /*color_enabled*/ false); assert_eq!( lines, vec![ @@ -1454,8 +1659,11 @@ mod tests { #[test] fn format_exit_messages_applies_color_when_enabled() { - let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000"), None); - let lines = format_exit_messages(exit_info, true); + let exit_info = sample_exit_info( + Some("123e4567-e89b-12d3-a456-426614174000"), + /*thread_name*/ None, + ); + let lines = format_exit_messages(exit_info, /*color_enabled*/ true); assert_eq!(lines.len(), 2); assert!(lines[1].contains("\u{1b}[36m")); } @@ -1466,7 +1674,7 @@ mod tests { Some("123e4567-e89b-12d3-a456-426614174000"), Some("my-thread"), ); - let lines = format_exit_messages(exit_info, false); + let lines = format_exit_messages(exit_info, /*color_enabled*/ false); assert_eq!( lines, vec![ @@ -1661,6 +1869,22 @@ mod tests { assert_eq!(cli.remote.remote.as_deref(), Some("ws://127.0.0.1:4500")); } + #[test] + fn remote_auth_token_env_flag_parses_for_interactive_root() { + let cli = MultitoolCli::try_parse_from([ + "codex", + "--remote-auth-token-env", + "CODEX_REMOTE_AUTH_TOKEN", + "--remote", + "ws://127.0.0.1:4500", + ]) + .expect("parse"); + assert_eq!( + cli.remote.remote_auth_token_env.as_deref(), + Some("CODEX_REMOTE_AUTH_TOKEN") + ); + } + #[test] fn remote_flag_parses_for_resume_subcommand() { let cli = @@ -1676,14 +1900,75 @@ mod tests { #[test] fn reject_remote_mode_for_non_interactive_subcommands() { - let err = reject_remote_mode_for_subcommand(Some("127.0.0.1:4500"), "exec") - .expect_err("non-interactive subcommands should reject --remote"); + let err = reject_remote_mode_for_subcommand( + Some("127.0.0.1:4500"), + /*remote_auth_token_env*/ None, + "exec", + ) + .expect_err("non-interactive subcommands should reject --remote"); assert!( err.to_string() .contains("only supported for interactive TUI commands") ); } + #[test] + fn reject_remote_auth_token_env_for_non_interactive_subcommands() { + let err = reject_remote_mode_for_subcommand( + /*remote*/ None, + Some("CODEX_REMOTE_AUTH_TOKEN"), + "exec", + ) + .expect_err("non-interactive subcommands should reject --remote-auth-token-env"); + assert!( + err.to_string() + .contains("only supported for interactive TUI commands") + ); + } + + #[test] + fn reject_remote_auth_token_env_for_app_server_generate_internal_json_schema() { + let subcommand = + AppServerSubcommand::GenerateInternalJsonSchema(GenerateInternalJsonSchemaCommand { + out_dir: PathBuf::from("/tmp/out"), + }); + let err = reject_remote_mode_for_app_server_subcommand( + /*remote*/ None, + Some("CODEX_REMOTE_AUTH_TOKEN"), + Some(&subcommand), + ) + .expect_err("non-interactive app-server subcommands should reject --remote-auth-token-env"); + assert!(err.to_string().contains("generate-internal-json-schema")); + } + + #[test] + fn read_remote_auth_token_from_env_var_reports_missing_values() { + let err = read_remote_auth_token_from_env_var_with("CODEX_REMOTE_AUTH_TOKEN", |_| { + Err(std::env::VarError::NotPresent) + }) + .expect_err("missing env vars should be rejected"); + assert!(err.to_string().contains("is not set")); + } + + #[test] + fn read_remote_auth_token_from_env_var_trims_values() { + let auth_token = + read_remote_auth_token_from_env_var_with("CODEX_REMOTE_AUTH_TOKEN", |_| { + Ok(" bearer-token ".to_string()) + }) + .expect("env var should parse"); + assert_eq!(auth_token, "bearer-token"); + } + + #[test] + fn read_remote_auth_token_from_env_var_rejects_empty_values() { + let err = read_remote_auth_token_from_env_var_with("CODEX_REMOTE_AUTH_TOKEN", |_| { + Ok(" \n\t ".to_string()) + }) + .expect_err("empty env vars should be rejected"); + assert!(err.to_string().contains("is empty")); + } + #[test] fn app_server_listen_websocket_url_parses() { let app_server = app_server_from_args( @@ -1707,6 +1992,12 @@ mod tests { ); } + #[test] + fn app_server_listen_off_parses() { + let app_server = app_server_from_args(["codex", "app-server", "--listen", "off"].as_ref()); + assert_eq!(app_server.listen, codex_app_server::AppServerTransport::Off); + } + #[test] fn app_server_listen_invalid_url_fails_to_parse() { let parse_result = diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 30a911cb63..f544ca82b7 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -6,26 +6,26 @@ use anyhow::Result; use anyhow::anyhow; use anyhow::bail; use clap::ArgGroup; +use codex_config::types::McpServerConfig; +use codex_config::types::McpServerTransportConfig; +use codex_core::McpManager; use codex_core::config::Config; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config::find_codex_home; use codex_core::config::load_global_mcp_servers; -use codex_core::config::types::McpServerConfig; -use codex_core::config::types::McpServerTransportConfig; -use codex_core::mcp::McpManager; -use codex_core::mcp::auth::McpOAuthLoginSupport; -use codex_core::mcp::auth::ResolvedMcpOAuthScopes; -use codex_core::mcp::auth::compute_auth_statuses; -use codex_core::mcp::auth::discover_supported_scopes; -use codex_core::mcp::auth::oauth_login_support; -use codex_core::mcp::auth::resolve_oauth_scopes; -use codex_core::mcp::auth::should_retry_without_scopes; use codex_core::plugins::PluginsManager; +use codex_mcp::McpOAuthLoginSupport; +use codex_mcp::ResolvedMcpOAuthScopes; +use codex_mcp::compute_auth_statuses; +use codex_mcp::discover_supported_scopes; +use codex_mcp::oauth_login_support; +use codex_mcp::resolve_oauth_scopes; +use codex_mcp::should_retry_without_scopes; use codex_protocol::protocol::McpAuthStatus; use codex_rmcp_client::delete_oauth_tokens; use codex_rmcp_client::perform_oauth_login; use codex_utils_cli::CliConfigOverrides; -use codex_utils_cli::format_env_display::format_env_display; +use codex_utils_cli::format_env_display; /// Subcommands: /// - `list` — list configured servers (with `--json`) @@ -194,7 +194,7 @@ impl McpCli { async fn perform_oauth_login_retry_without_scopes( name: &str, url: &str, - store_mode: codex_rmcp_client::OAuthCredentialsStoreMode, + store_mode: codex_config::types::OAuthCredentialsStoreMode, http_headers: Option>, env_http_headers: Option>, resolved_scopes: &ResolvedMcpOAuthScopes, @@ -306,6 +306,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }; servers.insert(name.clone(), new_entry); diff --git a/codex-rs/cli/src/wsl_paths.rs b/codex-rs/cli/src/wsl_paths.rs index b6ceb2e0bc..b90dc1e283 100644 --- a/codex-rs/cli/src/wsl_paths.rs +++ b/codex-rs/cli/src/wsl_paths.rs @@ -1,7 +1,7 @@ use std::ffi::OsStr; /// Returns true if the current process is running under WSL. -pub use codex_core::env::is_wsl; +pub use codex_utils_path::is_wsl; /// Convert a Windows absolute path (`C:\foo\bar` or `C:/foo/bar`) to a WSL mount path (`/mnt/c/foo/bar`). /// Returns `None` if the input does not look like a Windows drive path. diff --git a/codex-rs/cli/tests/mcp_add_remove.rs b/codex-rs/cli/tests/mcp_add_remove.rs index bc3fedc2a7..15afaf0828 100644 --- a/codex-rs/cli/tests/mcp_add_remove.rs +++ b/codex-rs/cli/tests/mcp_add_remove.rs @@ -1,8 +1,8 @@ use std::path::Path; use anyhow::Result; +use codex_config::types::McpServerTransportConfig; use codex_core::config::load_global_mcp_servers; -use codex_core::config::types::McpServerTransportConfig; use predicates::str::contains; use pretty_assertions::assert_eq; use tempfile::TempDir; diff --git a/codex-rs/cli/tests/mcp_list.rs b/codex-rs/cli/tests/mcp_list.rs index cb78644b1b..d41a3cc62a 100644 --- a/codex-rs/cli/tests/mcp_list.rs +++ b/codex-rs/cli/tests/mcp_list.rs @@ -1,9 +1,9 @@ use std::path::Path; use anyhow::Result; +use codex_config::types::McpServerTransportConfig; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config::load_global_mcp_servers; -use codex_core::config::types::McpServerTransportConfig; use predicates::prelude::PredicateBooleanExt; use predicates::str::contains; use pretty_assertions::assert_eq; diff --git a/codex-rs/cloud-requirements/Cargo.toml b/codex-rs/cloud-requirements/Cargo.toml index bbaa3a6db9..59f8741cdb 100644 --- a/codex-rs/cloud-requirements/Cargo.toml +++ b/codex-rs/cloud-requirements/Cargo.toml @@ -12,7 +12,9 @@ async-trait = { workspace = true } base64 = { workspace = true } chrono = { workspace = true, features = ["serde"] } codex-backend-client = { workspace = true } +codex-config = { workspace = true } codex-core = { workspace = true } +codex-login = { workspace = true } codex-otel = { workspace = true } codex-protocol = { workspace = true } hmac = "0.12.1" diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index fb0f62a342..ad4ebed12f 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -15,15 +15,15 @@ use chrono::DateTime; use chrono::Duration as ChronoDuration; use chrono::Utc; use codex_backend_client::Client as BackendClient; -use codex_core::AuthManager; -use codex_core::auth::AuthCredentialsStoreMode; -use codex_core::auth::CodexAuth; -use codex_core::auth::RefreshTokenError; +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; +use codex_login::RefreshTokenError; use codex_protocol::account::PlanType; use hmac::Hmac; use hmac::Mac; @@ -327,11 +327,11 @@ impl CloudRequirementsService { let Some(auth) = self.auth_manager.auth().await else { return Ok(None); }; + let Some(plan_type) = auth.account_plan_type() else { + return Ok(None); + }; if !auth.is_chatgpt_auth() - || !matches!( - auth.account_plan_type(), - Some(PlanType::Business | PlanType::Enterprise) - ) + || !(plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise)) { return Ok(None); } @@ -547,11 +547,11 @@ impl CloudRequirementsService { let Some(auth) = self.auth_manager.auth().await else { return false; }; + let Some(plan_type) = auth.account_plan_type() else { + return false; + }; if !auth.is_chatgpt_auth() - || !matches!( - auth.account_plan_type(), - Some(PlanType::Business | PlanType::Enterprise) - ) + || !(plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise)) { return false; } @@ -806,7 +806,7 @@ fn status_code_tag(status_code: Option) -> String { } fn emit_metric(metric_name: &str, tags: Vec<(&str, String)>) { - if let Some(metrics) = codex_otel::metrics::global() { + if let Some(metrics) = codex_otel::global() { let tag_refs = tags .iter() .map(|(key, value)| (*key, value.as_str())) @@ -820,7 +820,7 @@ mod tests { use super::*; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use codex_core::auth::AuthCredentialsStoreMode; + use codex_config::types::AuthCredentialsStoreMode; use codex_protocol::protocol::AskForApproval; use pretty_assertions::assert_eq; use serde_json::json; @@ -848,7 +848,7 @@ mod tests { write_auth_json(tmp.path(), auth_json).expect("write auth"); Arc::new(AuthManager::new( tmp.path().to_path_buf(), - false, + /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, )) } @@ -872,7 +872,7 @@ mod tests { .expect("write auth"); Arc::new(AuthManager::new( tmp.path().to_path_buf(), - false, + /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, )) } @@ -909,7 +909,7 @@ mod tests { access_token, refresh_token, last_refresh, - None, + /*auth_mode*/ None, ) } @@ -980,7 +980,7 @@ mod tests { ManagedAuthContext { manager: Arc::new(AuthManager::new( home.path().to_path_buf(), - false, + /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, )), _home: home, @@ -1125,6 +1125,20 @@ mod tests { assert_eq!(result, Ok(None)); } + #[tokio::test] + 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"), + Arc::new(StaticFetcher { + contents: Some("allowed_approval_policies = [\"never\"]".to_string()), + }), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + assert_eq!(service.fetch().await, Ok(None)); + } + #[tokio::test] async fn fetch_cloud_requirements_allows_business_plan() { let codex_home = tempdir().expect("tempdir"); @@ -1140,6 +1154,65 @@ mod tests { service.fetch().await, Ok(Some(ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + allowed_web_search_modes: None, + guardian_developer_instructions: None, + feature_requirements: None, + mcp_servers: None, + apps: None, + rules: None, + enforce_residency: None, + network: None, + })) + ); + } + + #[tokio::test] + 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"), + Arc::new(StaticFetcher { + contents: Some("allowed_approval_policies = [\"never\"]".to_string()), + }), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + assert_eq!( + service.fetch().await, + Ok(Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + allowed_web_search_modes: None, + guardian_developer_instructions: None, + feature_requirements: None, + mcp_servers: None, + apps: None, + rules: None, + enforce_residency: None, + network: None, + })) + ); + } + + #[tokio::test] + 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"), + Arc::new(StaticFetcher { + contents: Some("allowed_approval_policies = [\"never\"]".to_string()), + }), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + assert_eq!( + service.fetch().await, + Ok(Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_developer_instructions: None, @@ -1155,7 +1228,7 @@ mod tests { #[tokio::test] async fn fetch_cloud_requirements_handles_missing_contents() { - let result = parse_for_fetch(None); + let result = parse_for_fetch(/*contents*/ None); assert!(result.is_none()); } @@ -1185,6 +1258,7 @@ mod tests { result, Some(ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_developer_instructions: None, @@ -1266,6 +1340,7 @@ enabled = false handle.await.expect("cloud requirements task"), Ok(Some(ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_developer_instructions: None, @@ -1299,7 +1374,7 @@ enabled = false .expect("write initial auth"); let auth_manager = Arc::new(AuthManager::new( auth_home.path().to_path_buf(), - false, + /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, )); @@ -1337,6 +1412,7 @@ enabled = false service.fetch().await, Ok(Some(ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_developer_instructions: None, @@ -1368,7 +1444,7 @@ enabled = false .expect("write initial auth"); let auth_manager = Arc::new(AuthManager::new( auth_home.path().to_path_buf(), - false, + /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, )); @@ -1406,6 +1482,7 @@ enabled = false service.fetch().await, Ok(Some(ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_developer_instructions: None, @@ -1495,7 +1572,7 @@ enabled = false .expect("write auth"); let auth_manager = Arc::new(AuthManager::new( auth_home.path().to_path_buf(), - false, + /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, )); @@ -1569,6 +1646,7 @@ enabled = false service.fetch().await, Ok(Some(ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_developer_instructions: None, @@ -1587,7 +1665,11 @@ enabled = false async fn fetch_cloud_requirements_writes_cache_when_identity_is_incomplete() { let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( - auth_manager_with_plan_and_identity("business", None, Some("account-12345")), + auth_manager_with_plan_and_identity( + "business", + /*chatgpt_user_id*/ None, + Some("account-12345"), + ), Arc::new(StaticFetcher { contents: Some("allowed_approval_policies = [\"never\"]".to_string()), }), @@ -1599,6 +1681,7 @@ enabled = false service.fetch().await, Ok(Some(ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_developer_instructions: None, @@ -1639,7 +1722,11 @@ enabled = false "allowed_approval_policies = [\"on-request\"]".to_string(), ))])); let service = CloudRequirementsService::new( - auth_manager_with_plan_and_identity("business", None, Some("account-12345")), + auth_manager_with_plan_and_identity( + "business", + /*chatgpt_user_id*/ None, + Some("account-12345"), + ), fetcher.clone(), codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, @@ -1649,6 +1736,7 @@ enabled = false service.fetch().await, Ok(Some(ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), + allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_developer_instructions: None, @@ -1698,6 +1786,7 @@ enabled = false service.fetch().await, Ok(Some(ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), + allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_developer_instructions: None, @@ -1751,6 +1840,7 @@ enabled = false service.fetch().await, Ok(Some(ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_developer_instructions: None, @@ -1805,6 +1895,7 @@ enabled = false service.fetch().await, Ok(Some(ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_developer_instructions: None, @@ -1859,6 +1950,7 @@ enabled = false .and_then(|contents| parse_cloud_requirements(contents).ok().flatten()), Some(ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_developer_instructions: None, @@ -1946,6 +2038,7 @@ enabled = false service.fetch().await, Ok(Some(ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_developer_instructions: None, @@ -1972,6 +2065,7 @@ enabled = false .and_then(|contents| parse_cloud_requirements(contents).ok().flatten()), Some(ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), + allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, guardian_developer_instructions: None, diff --git a/codex-rs/cloud-tasks-client/BUILD.bazel b/codex-rs/cloud-tasks-client/BUILD.bazel index cbbd47b761..83157266a5 100644 --- a/codex-rs/cloud-tasks-client/BUILD.bazel +++ b/codex-rs/cloud-tasks-client/BUILD.bazel @@ -3,8 +3,4 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "cloud-tasks-client", crate_name = "codex_cloud_tasks_client", - crate_features = [ - "mock", - "online", - ], ) diff --git a/codex-rs/cloud-tasks-client/Cargo.toml b/codex-rs/cloud-tasks-client/Cargo.toml index 6774d46fdc..cdfcba47b8 100644 --- a/codex-rs/cloud-tasks-client/Cargo.toml +++ b/codex-rs/cloud-tasks-client/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "codex-cloud-tasks-client" -version.workspace = true edition.workspace = true license.workspace = true +name = "codex-cloud-tasks-client" +version.workspace = true [lib] name = "codex_cloud_tasks_client" @@ -11,18 +11,12 @@ path = "src/lib.rs" [lints] workspace = true -[features] -default = ["online"] -online = ["dep:codex-backend-client"] -mock = [] - [dependencies] -anyhow = "1" -async-trait = "0.1" -chrono = { version = "0.4", features = ["serde"] } -diffy = "0.4.2" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -thiserror = "2.0.17" -codex-backend-client = { path = "../backend-client", optional = true } +anyhow = { workspace = true } +async-trait = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +codex-backend-client = { workspace = true } codex-git-utils = { workspace = true } +serde = { version = "1", features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/codex-rs/cloud-tasks-client/src/lib.rs b/codex-rs/cloud-tasks-client/src/lib.rs index 9519050bae..8ed6469a53 100644 --- a/codex-rs/cloud-tasks-client/src/lib.rs +++ b/codex-rs/cloud-tasks-client/src/lib.rs @@ -15,16 +15,5 @@ pub use api::TaskSummary; pub use api::TaskText; pub use api::TurnAttempt; -#[cfg(feature = "mock")] -mod mock; - -#[cfg(feature = "online")] mod http; - -#[cfg(feature = "mock")] -pub use mock::MockClient; - -#[cfg(feature = "online")] pub use http::HttpClient; - -// Reusable apply engine now lives in the shared crate `codex-git-utils`. diff --git a/codex-rs/cloud-tasks-mock-client/BUILD.bazel b/codex-rs/cloud-tasks-mock-client/BUILD.bazel new file mode 100644 index 0000000000..4d54dab57e --- /dev/null +++ b/codex-rs/cloud-tasks-mock-client/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "cloud-tasks-mock-client", + crate_name = "codex_cloud_tasks_mock_client", +) diff --git a/codex-rs/cloud-tasks-mock-client/Cargo.toml b/codex-rs/cloud-tasks-mock-client/Cargo.toml new file mode 100644 index 0000000000..7282929382 --- /dev/null +++ b/codex-rs/cloud-tasks-mock-client/Cargo.toml @@ -0,0 +1,19 @@ + +[package] +edition.workspace = true +license.workspace = true +name = "codex-cloud-tasks-mock-client" +version.workspace = true + +[lib] +name = "codex_cloud_tasks_mock_client" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +async-trait = { workspace = true } +chrono = { workspace = true } +codex-cloud-tasks-client = { workspace = true } +diffy = { workspace = true } diff --git a/codex-rs/cloud-tasks-mock-client/src/lib.rs b/codex-rs/cloud-tasks-mock-client/src/lib.rs new file mode 100644 index 0000000000..833ea5e1f8 --- /dev/null +++ b/codex-rs/cloud-tasks-mock-client/src/lib.rs @@ -0,0 +1,3 @@ +mod mock; + +pub use mock::MockClient; diff --git a/codex-rs/cloud-tasks-client/src/mock.rs b/codex-rs/cloud-tasks-mock-client/src/mock.rs similarity index 88% rename from codex-rs/cloud-tasks-client/src/mock.rs rename to codex-rs/cloud-tasks-mock-client/src/mock.rs index a679f617bc..4bde0e93b9 100644 --- a/codex-rs/cloud-tasks-client/src/mock.rs +++ b/codex-rs/cloud-tasks-mock-client/src/mock.rs @@ -1,15 +1,18 @@ -use crate::ApplyOutcome; -use crate::AttemptStatus; -use crate::CloudBackend; -use crate::CloudTaskError; -use crate::DiffSummary; -use crate::Result; -use crate::TaskId; -use crate::TaskStatus; -use crate::TaskSummary; -use crate::TurnAttempt; -use crate::api::TaskText; use chrono::Utc; +use codex_cloud_tasks_client::ApplyOutcome; +use codex_cloud_tasks_client::ApplyStatus; +use codex_cloud_tasks_client::AttemptStatus; +use codex_cloud_tasks_client::CloudBackend; +use codex_cloud_tasks_client::CloudTaskError; +use codex_cloud_tasks_client::CreatedTask; +use codex_cloud_tasks_client::DiffSummary; +use codex_cloud_tasks_client::Result; +use codex_cloud_tasks_client::TaskId; +use codex_cloud_tasks_client::TaskListPage; +use codex_cloud_tasks_client::TaskStatus; +use codex_cloud_tasks_client::TaskSummary; +use codex_cloud_tasks_client::TaskText; +use codex_cloud_tasks_client::TurnAttempt; #[derive(Clone, Default)] pub struct MockClient; @@ -21,7 +24,7 @@ impl CloudBackend for MockClient { _env: Option<&str>, _limit: Option, _cursor: Option<&str>, - ) -> Result { + ) -> Result { // Slightly vary content by env to aid tests that rely on the mock let rows = match _env { Some("env-A") => vec![("T-2000", "A: First", TaskStatus::Ready)], @@ -63,7 +66,7 @@ impl CloudBackend for MockClient { attempt_total: Some(if id_str == "T-1000" { 2 } else { 1 }), }); } - Ok(crate::TaskListPage { + Ok(TaskListPage { tasks: out, cursor: None, }) @@ -104,7 +107,7 @@ impl CloudBackend for MockClient { async fn apply_task(&self, id: TaskId, _diff_override: Option) -> Result { Ok(ApplyOutcome { applied: true, - status: crate::ApplyStatus::Success, + status: ApplyStatus::Success, message: format!("Applied task {} locally (mock)", id.0), skipped_paths: Vec::new(), conflict_paths: Vec::new(), @@ -118,7 +121,7 @@ impl CloudBackend for MockClient { ) -> Result { Ok(ApplyOutcome { applied: false, - status: crate::ApplyStatus::Success, + status: ApplyStatus::Success, message: format!("Preflight passed for task {} (mock)", id.0), skipped_paths: Vec::new(), conflict_paths: Vec::new(), @@ -150,10 +153,10 @@ impl CloudBackend for MockClient { git_ref: &str, qa_mode: bool, best_of_n: usize, - ) -> Result { + ) -> Result { let _ = (env_id, prompt, git_ref, qa_mode, best_of_n); let id = format!("task_local_{}", chrono::Utc::now().timestamp_millis()); - Ok(crate::CreatedTask { id: TaskId(id) }) + Ok(CreatedTask { id: TaskId(id) }) } } diff --git a/codex-rs/cloud-tasks/Cargo.toml b/codex-rs/cloud-tasks/Cargo.toml index 4ba9968fb0..30e8b73a8f 100644 --- a/codex-rs/cloud-tasks/Cargo.toml +++ b/codex-rs/cloud-tasks/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "codex-cloud-tasks" -version.workspace = true edition.workspace = true license.workspace = true +name = "codex-cloud-tasks" +version.workspace = true [lib] name = "codex_cloud_tasks" @@ -16,32 +16,30 @@ anyhow = { workspace = true } base64 = { workspace = true } chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["derive"] } -codex-cloud-tasks-client = { path = "../cloud-tasks-client", features = [ - "mock", - "online", -] } codex-client = { workspace = true } -codex-core = { path = "../core" } +codex-cloud-tasks-client = { workspace = true } +# TODO: codex-cloud-tasks-mock-client should be in dev-dependencies. +codex-cloud-tasks-mock-client = { workspace = true } +codex-core = { workspace = true } codex-git-utils = { workspace = true } codex-login = { path = "../login" } -codex-tui = { path = "../tui" } +codex-tui = { workspace = true } codex-utils-cli = { workspace = true } crossterm = { workspace = true, features = ["event-stream"] } +owo-colors = { workspace = true, features = ["supports-colors"] } ratatui = { workspace = true } reqwest = { workspace = true, features = ["json"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +supports-color = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } tokio-stream = { workspace = true } tracing = { workspace = true, features = ["log"] } tracing-subscriber = { workspace = true, features = ["env-filter"] } unicode-width = { workspace = true } -owo-colors = { workspace = true, features = ["supports-colors"] } -supports-color = { workspace = true } [dependencies.async-trait] workspace = true [dev-dependencies] -async-trait = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/cloud-tasks/src/app.rs b/codex-rs/cloud-tasks/src/app.rs index 4a8ca9900a..aa02be97f1 100644 --- a/codex-rs/cloud-tasks/src/app.rs +++ b/codex-rs/cloud-tasks/src/app.rs @@ -408,7 +408,7 @@ mod tests { &self, id: TaskId, ) -> codex_cloud_tasks_client::Result { - self.list_tasks(None, None, None) + self.list_tasks(/*env*/ None, /*limit*/ None, /*cursor*/ None) .await? .tasks .into_iter() @@ -497,7 +497,7 @@ mod tests { let backend = FakeBackend { by_env }; // Act + Assert - let root = load_tasks(&backend, None).await.unwrap(); + let root = load_tasks(&backend, /*env*/ None).await.unwrap(); assert_eq!(root.len(), 2); assert_eq!(root[0].title, "root-1"); diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index 7766c1eb8b..7006d52b92 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -1,10 +1,10 @@ mod app; mod cli; -pub mod env_detect; +pub(crate) mod env_detect; mod new_task; -pub mod scrollable_diff; +pub(crate) mod scrollable_diff; mod ui; -pub mod util; +pub(crate) mod util; pub use cli::Cli; use anyhow::anyhow; @@ -12,6 +12,7 @@ use chrono::Utc; use codex_cloud_tasks_client::TaskStatus; use codex_git_utils::current_branch_name; use codex_git_utils::default_branch_name; +use codex_login::default_client::get_codex_user_agent; use owo_colors::OwoColorize; use owo_colors::Stream; use std::cmp::Ordering; @@ -40,6 +41,7 @@ struct BackendContext { } async fn init_backend(user_agent_suffix: &str) -> anyhow::Result { + #[cfg(debug_assertions)] let use_mock = matches!( std::env::var("CODEX_CLOUD_TASKS_MODE").ok().as_deref(), Some("mock") | Some("MOCK") @@ -49,14 +51,15 @@ async fn init_backend(user_agent_suffix: &str) -> anyhow::Result set_user_agent_suffix(user_agent_suffix); + #[cfg(debug_assertions)] if use_mock { return Ok(BackendContext { - backend: Arc::new(codex_cloud_tasks_client::MockClient), + backend: Arc::new(codex_cloud_tasks_mock_client::MockClient), base_url, }); } - let ua = codex_core::default_client::get_codex_user_agent(); + let ua = get_codex_user_agent(); let mut http = codex_cloud_tasks_client::HttpClient::new(base_url.clone())?.with_user_agent(ua); let style = if base_url.contains("/backend-api") { "wham" @@ -802,7 +805,7 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an append_error_log(format!( "startup: wham_force_internal={} ua={}", force_internal, - codex_core::default_client::get_codex_user_agent() + get_codex_user_agent() )); // Non-blocking initial load so the in-box spinner can animate app.status = "Loading tasks…".to_string(); @@ -930,7 +933,8 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an if let Some(page) = app.new_task.as_mut() { if page.composer.flush_paste_burst_if_due() { needs_redraw = true; } if page.composer.is_in_paste_burst() { - let _ = frame_tx.send(Instant::now() + codex_tui::ComposerInput::recommended_flush_delay()); + let _ = frame_tx + .send(Instant::now() + codex_tui::ComposerInput::recommended_flush_delay()); } } // Keep spinner pulsing only while loading. @@ -1491,7 +1495,9 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an _ => { if page.submitting { // Ignore input while submitting - } else if let codex_tui::ComposerAction::Submitted(text) = page.composer.input(key) { + } else if let codex_tui::ComposerAction::Submitted(text) = + page.composer.input(key) + { // Submit only if we have an env id if let Some(env) = page.env_id.clone() { append_error_log(format!( @@ -1521,7 +1527,10 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an needs_redraw = true; // If paste‑burst is active, schedule a micro‑flush frame. if page.composer.is_in_paste_burst() { - let _ = frame_tx.send(Instant::now() + codex_tui::ComposerInput::recommended_flush_delay()); + let _ = frame_tx.send( + Instant::now() + + codex_tui::ComposerInput::recommended_flush_delay(), + ); } // Always schedule an immediate redraw for key edits in the composer. let _ = frame_tx.send(Instant::now()); @@ -1587,7 +1596,7 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an let total = ov.attempt_display_total(); let current = ov.selected_attempt + 1; app.status = format!("Viewing attempt {current} of {total}"); - ov.sd.to_top(); + ov.sd.scroll_to_top(); needs_redraw = true; } }; @@ -1663,7 +1672,7 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an let has_diff = ov.current_attempt().is_some_and(app::AttemptView::has_diff) || ov.base_can_apply; if has_text && has_diff { ov.set_view(app::DetailView::Prompt); - ov.sd.to_top(); + ov.sd.scroll_to_top(); needs_redraw = true; } } @@ -1674,7 +1683,7 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an let has_diff = ov.current_attempt().is_some_and(app::AttemptView::has_diff) || ov.base_can_apply; if has_text && has_diff { ov.set_view(app::DetailView::Diff); - ov.sd.to_top(); + ov.sd.scroll_to_top(); needs_redraw = true; } } @@ -1705,8 +1714,8 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an if let Some(ov) = &mut app.diff_overlay { let step = ov.sd.state.viewport_h.saturating_sub(1) as i16; ov.sd.page_by(-step); } needs_redraw = true; } - KeyCode::Home => { if let Some(ov) = &mut app.diff_overlay { ov.sd.to_top(); } needs_redraw = true; } - KeyCode::End => { if let Some(ov) = &mut app.diff_overlay { ov.sd.to_bottom(); } needs_redraw = true; } + KeyCode::Home => { if let Some(ov) = &mut app.diff_overlay { ov.sd.scroll_to_top(); } needs_redraw = true; } + KeyCode::End => { if let Some(ov) = &mut app.diff_overlay { ov.sd.scroll_to_bottom(); } needs_redraw = true; } _ => {} } } else if app.env_modal.is_some() { @@ -2126,10 +2135,10 @@ mod tests { use super::*; use crate::resolve_git_ref_with_git_info; use codex_cloud_tasks_client::DiffSummary; - use codex_cloud_tasks_client::MockClient; use codex_cloud_tasks_client::TaskId; use codex_cloud_tasks_client::TaskStatus; use codex_cloud_tasks_client::TaskSummary; + use codex_cloud_tasks_mock_client::MockClient; use codex_tui::ComposerAction; use codex_tui::ComposerInput; use crossterm::event::KeyCode; @@ -2168,7 +2177,7 @@ mod tests { async fn branch_override_is_used_when_provided() { let git_ref = resolve_git_ref_with_git_info( Some(&"feature/override".to_string()), - &StubGitInfo::new(None, None), + &StubGitInfo::new(/*default_branch*/ None, /*current_branch*/ None), ) .await; @@ -2179,7 +2188,7 @@ mod tests { async fn trims_override_whitespace() { let git_ref = resolve_git_ref_with_git_info( Some(&" feature/spaces ".to_string()), - &StubGitInfo::new(None, None), + &StubGitInfo::new(/*default_branch*/ None, /*current_branch*/ None), ) .await; @@ -2189,7 +2198,7 @@ mod tests { #[tokio::test] async fn prefers_current_branch_when_available() { let git_ref = resolve_git_ref_with_git_info( - None, + /*branch_override*/ None, &StubGitInfo::new( Some("default-main".to_string()), Some("feature/current".to_string()), @@ -2203,8 +2212,8 @@ mod tests { #[tokio::test] async fn falls_back_to_current_branch_when_default_is_missing() { let git_ref = resolve_git_ref_with_git_info( - None, - &StubGitInfo::new(None, Some("develop".to_string())), + /*branch_override*/ None, + &StubGitInfo::new(/*default_branch*/ None, Some("develop".to_string())), ) .await; @@ -2213,7 +2222,11 @@ mod tests { #[tokio::test] async fn falls_back_to_main_when_no_git_info_is_available() { - let git_ref = resolve_git_ref_with_git_info(None, &StubGitInfo::new(None, None)).await; + let git_ref = resolve_git_ref_with_git_info( + /*branch_override*/ None, + &StubGitInfo::new(/*default_branch*/ None, /*current_branch*/ None), + ) + .await; assert_eq!(git_ref, "main"); } @@ -2236,7 +2249,7 @@ mod tests { is_review: false, attempt_total: None, }; - let lines = format_task_status_lines(&task, now, false); + let lines = format_task_status_lines(&task, now, /*colorize*/ false); assert_eq!( lines, vec![ @@ -2261,7 +2274,7 @@ mod tests { is_review: false, attempt_total: Some(1), }; - let lines = format_task_status_lines(&task, now, false); + let lines = format_task_status_lines(&task, now, /*colorize*/ false); assert_eq!( lines, vec![ @@ -2303,7 +2316,12 @@ mod tests { attempt_total: Some(1), }, ]; - let lines = format_task_list_lines(&tasks, "https://chatgpt.com/backend-api", now, false); + let lines = format_task_list_lines( + &tasks, + "https://chatgpt.com/backend-api", + now, + /*colorize*/ false, + ); assert_eq!( lines, vec![ diff --git a/codex-rs/cloud-tasks/src/scrollable_diff.rs b/codex-rs/cloud-tasks/src/scrollable_diff.rs index 97dfb24895..59dd076b36 100644 --- a/codex-rs/cloud-tasks/src/scrollable_diff.rs +++ b/codex-rs/cloud-tasks/src/scrollable_diff.rs @@ -86,11 +86,11 @@ impl ScrollableDiff { self.scroll_by(delta); } - pub fn to_top(&mut self) { + pub fn scroll_to_top(&mut self) { self.state.scroll = 0; } - pub fn to_bottom(&mut self) { + pub fn scroll_to_bottom(&mut self) { self.state.scroll = self.max_scroll(); } diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index 0dcf18432a..090eec227e 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -8,7 +8,7 @@ use codex_core::config::Config; use codex_login::AuthManager; pub fn set_user_agent_suffix(suffix: &str) { - if let Ok(mut guard) = codex_core::default_client::USER_AGENT_SUFFIX.lock() { + if let Ok(mut guard) = codex_login::default_client::USER_AGENT_SUFFIX.lock() { guard.replace(suffix.to_string()); } } @@ -78,7 +78,7 @@ pub async fn build_chatgpt_headers() -> HeaderMap { use reqwest::header::USER_AGENT; set_user_agent_suffix("codex_cloud_tasks_tui"); - let ua = codex_core::default_client::get_codex_user_agent(); + let ua = codex_login::default_client::get_codex_user_agent(); let mut headers = HeaderMap::new(); headers.insert( USER_AGENT, diff --git a/codex-rs/cloud-tasks/tests/env_filter.rs b/codex-rs/cloud-tasks/tests/env_filter.rs index 688ccd29bd..716e4f05ed 100644 --- a/codex-rs/cloud-tasks/tests/env_filter.rs +++ b/codex-rs/cloud-tasks/tests/env_filter.rs @@ -1,27 +1,39 @@ use codex_cloud_tasks_client::CloudBackend; -use codex_cloud_tasks_client::MockClient; +use codex_cloud_tasks_mock_client::MockClient; #[tokio::test] async fn mock_backend_varies_by_env() { let client = MockClient; - let root = CloudBackend::list_tasks(&client, None, None, None) - .await - .unwrap() - .tasks; + let root = CloudBackend::list_tasks( + &client, /*env*/ None, /*limit*/ None, /*cursor*/ None, + ) + .await + .unwrap() + .tasks; assert!(root.iter().any(|t| t.title.contains("Update README"))); - let a = CloudBackend::list_tasks(&client, Some("env-A"), None, None) - .await - .unwrap() - .tasks; + let a = CloudBackend::list_tasks( + &client, + Some("env-A"), + /*limit*/ None, + /*cursor*/ None, + ) + .await + .unwrap() + .tasks; assert_eq!(a.len(), 1); assert_eq!(a[0].title, "A: First"); - let b = CloudBackend::list_tasks(&client, Some("env-B"), None, None) - .await - .unwrap() - .tasks; + let b = CloudBackend::list_tasks( + &client, + Some("env-B"), + /*limit*/ None, + /*cursor*/ None, + ) + .await + .unwrap() + .tasks; assert_eq!(b.len(), 2); assert!(b[0].title.starts_with("B: ")); } diff --git a/codex-rs/code-mode/src/description.rs b/codex-rs/code-mode/src/description.rs index c875e2a1b1..76ede634ba 100644 --- a/codex-rs/code-mode/src/description.rs +++ b/codex-rs/code-mode/src/description.rs @@ -7,15 +7,17 @@ use crate::PUBLIC_TOOL_NAME; const MAX_JS_SAFE_INTEGER: u64 = (1_u64 << 53) - 1; const CODE_MODE_ONLY_PREFACE: &str = "Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly"; -const EXEC_DESCRIPTION_TEMPLATE: &str = r#"## exec -- Runs raw JavaScript in an isolated context (no Node, no file system, or network access, no console). -- Send raw JavaScript source text, not JSON, quoted strings, or markdown code fences. +const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/compose tool calls +- Evaluates the provided JavaScript code in a fresh V8 isolate as an async module. +- All nested tools are available on the global `tools` object, for example `await tools.exec_command(...)`. Tool names are exposed as normalized JavaScript identifiers, for example `await tools.mcp__ologs__get_profile(...)`. +- Nested tool methods take either a string or an object as their input argument. +- Nested tools return either an object or a string, based on the description. +- Runs raw JavaScript -- no Node, no file system, no network access, no console. +- Accepts raw JavaScript source text, not JSON, quoted strings, or markdown code fences. - You may optionally start the tool input with a first-line pragma like `// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}`. - `yield_time_ms` asks `exec` to yield early after that many milliseconds if the script is still running. - `max_output_tokens` sets the token budget for direct `exec` results. By default the result is truncated to 10000 tokens. -- All nested tools are available on the global `tools` object, for example `await tools.exec_command(...)`. Tool names are exposed as normalized JavaScript identifiers, for example `await tools.mcp__ologs__get_profile(...)`. -- Tool methods take either string or object as parameter. -- They return either a structured value or a string based on the description above. +- When the JS code is fully evaluated, the isolate's lifetime ends and unawaited promises are silently discarded. - Global helpers: - `exit()`: Immediately ends the current script successfully (like an early return from the top level). @@ -24,6 +26,8 @@ const EXEC_DESCRIPTION_TEMPLATE: &str = r#"## exec - `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session. - `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing. - `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`. +- `setTimeout(callback: () => void, delayMs?: number)`: schedules a callback to run later and returns a timeout id. Pending timeouts do not keep `exec` alive by themselves; await an explicit promise if you need to wait for one. +- `clearTimeout(timeoutId?: number)`: cancels a timeout created by `setTimeout`. - `ALL_TOOLS`: metadata for the enabled nested tools as `{ name, description }` entries. - `yield_control()`: yields the accumulated output to the model immediately while the script keeps running."#; const WAIT_DESCRIPTION_TEMPLATE: &str = r#"- Use `wait` only after `exec` returns `Script running with cell ID ...`. @@ -548,8 +552,17 @@ mod tests { #[test] fn code_mode_only_description_includes_nested_tools() { - let description = - build_exec_tool_description(&[("foo".to_string(), "bar".to_string())], true); + let description = build_exec_tool_description( + &[("foo".to_string(), "bar".to_string())], + /*code_mode_only*/ true, + ); assert!(description.contains("### `foo` (`foo`)")); } + + #[test] + fn exec_description_mentions_timeout_helpers() { + let description = build_exec_tool_description(&[], /*code_mode_only*/ false); + assert!(description.contains("`setTimeout(callback: () => void, delayMs?: number)`")); + assert!(description.contains("`clearTimeout(timeoutId?: number)`")); + } } diff --git a/codex-rs/code-mode/src/runtime/callbacks.rs b/codex-rs/code-mode/src/runtime/callbacks.rs index b77ae82d68..5511baca23 100644 --- a/codex-rs/code-mode/src/runtime/callbacks.rs +++ b/codex-rs/code-mode/src/runtime/callbacks.rs @@ -3,6 +3,7 @@ use crate::response::FunctionCallOutputContentItem; use super::EXIT_SENTINEL; use super::RuntimeEvent; use super::RuntimeState; +use super::timers; use super::value::json_to_v8; use super::value::normalize_output_image; use super::value::serialize_output_text; @@ -185,6 +186,35 @@ pub(super) fn notify_callback( retval.set(v8::undefined(scope).into()); } +pub(super) fn set_timeout_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let timeout_id = match timers::schedule_timeout(scope, args) { + Ok(timeout_id) => timeout_id, + Err(error_text) => { + throw_type_error(scope, &error_text); + return; + } + }; + + retval.set(v8::Number::new(scope, timeout_id as f64).into()); +} + +pub(super) fn clear_timeout_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + if let Err(error_text) = timers::clear_timeout(scope, args) { + throw_type_error(scope, &error_text); + return; + } + + retval.set(v8::undefined(scope).into()); +} + pub(super) fn yield_control_callback( scope: &mut v8::PinScope<'_, '_>, _args: v8::FunctionCallbackArguments, diff --git a/codex-rs/code-mode/src/runtime/globals.rs b/codex-rs/code-mode/src/runtime/globals.rs index 371479497b..2d419db908 100644 --- a/codex-rs/code-mode/src/runtime/globals.rs +++ b/codex-rs/code-mode/src/runtime/globals.rs @@ -1,8 +1,10 @@ use super::RuntimeState; +use super::callbacks::clear_timeout_callback; use super::callbacks::exit_callback; use super::callbacks::image_callback; use super::callbacks::load_callback; use super::callbacks::notify_callback; +use super::callbacks::set_timeout_callback; use super::callbacks::store_callback; use super::callbacks::text_callback; use super::callbacks::tool_callback; @@ -18,6 +20,8 @@ pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), St let tools = build_tools_object(scope)?; let all_tools = build_all_tools_value(scope)?; + let clear_timeout = helper_function(scope, "clearTimeout", clear_timeout_callback)?; + let set_timeout = helper_function(scope, "setTimeout", set_timeout_callback)?; let text = helper_function(scope, "text", text_callback)?; let image = helper_function(scope, "image", image_callback)?; let store = helper_function(scope, "store", store_callback)?; @@ -28,6 +32,8 @@ pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), St set_global(scope, global, "tools", tools.into())?; set_global(scope, global, "ALL_TOOLS", all_tools)?; + set_global(scope, global, "clearTimeout", clear_timeout.into())?; + set_global(scope, global, "setTimeout", set_timeout.into())?; set_global(scope, global, "text", text.into())?; set_global(scope, global, "image", image.into())?; set_global(scope, global, "store", store.into())?; diff --git a/codex-rs/code-mode/src/runtime/mod.rs b/codex-rs/code-mode/src/runtime/mod.rs index df90eda673..411f81bddc 100644 --- a/codex-rs/code-mode/src/runtime/mod.rs +++ b/codex-rs/code-mode/src/runtime/mod.rs @@ -1,6 +1,7 @@ mod callbacks; mod globals; mod module_loader; +mod timers; mod value; use std::collections::HashMap; @@ -75,6 +76,7 @@ pub(crate) enum TurnMessage { pub(crate) enum RuntimeCommand { ToolResponse { id: String, result: JsonValue }, ToolError { id: String, error_text: String }, + TimeoutFired { id: u64 }, Terminate, } @@ -103,6 +105,7 @@ pub(crate) fn spawn_runtime( event_tx: mpsc::UnboundedSender, ) -> Result<(std_mpsc::Sender, v8::IsolateHandle), String> { let (command_tx, command_rx) = std_mpsc::channel(); + let runtime_command_tx = command_tx.clone(); let (isolate_handle_tx, isolate_handle_rx) = std_mpsc::sync_channel(1); let enabled_tools = request .enabled_tools @@ -117,7 +120,13 @@ pub(crate) fn spawn_runtime( }; thread::spawn(move || { - run_runtime(config, event_tx, command_rx, isolate_handle_tx); + run_runtime( + config, + event_tx, + command_rx, + isolate_handle_tx, + runtime_command_tx, + ); }); let isolate_handle = isolate_handle_rx @@ -137,10 +146,13 @@ struct RuntimeConfig { pub(super) struct RuntimeState { event_tx: mpsc::UnboundedSender, pending_tool_calls: HashMap>, + pending_timeouts: HashMap, stored_values: HashMap, enabled_tools: Vec, next_tool_call_id: u64, + next_timeout_id: u64, tool_call_id: String, + runtime_command_tx: std_mpsc::Sender, exit_requested: bool, } @@ -168,6 +180,7 @@ fn run_runtime( event_tx: mpsc::UnboundedSender, command_rx: std_mpsc::Receiver, isolate_handle_tx: std_mpsc::SyncSender, + runtime_command_tx: std_mpsc::Sender, ) { initialize_v8(); @@ -185,10 +198,13 @@ fn run_runtime( scope.set_slot(RuntimeState { event_tx: event_tx.clone(), pending_tool_calls: HashMap::new(), + pending_timeouts: HashMap::new(), stored_values: config.stored_values, enabled_tools: config.enabled_tools, next_tool_call_id: 1, + next_timeout_id: 1, tool_call_id: config.tool_call_id, + runtime_command_tx, exit_requested: false, }); @@ -223,6 +239,7 @@ fn run_runtime( let Ok(command) = command_rx.recv() else { break; }; + match command { RuntimeCommand::Terminate => break, RuntimeCommand::ToolResponse { id, result } => { @@ -241,6 +258,12 @@ fn run_runtime( return; } } + RuntimeCommand::TimeoutFired { id } => { + if let Err(runtime_error) = timers::invoke_timeout_callback(scope, id) { + capture_scope_send_error(scope, &event_tx, Some(runtime_error)); + return; + } + } } scope.perform_microtask_checkpoint(); diff --git a/codex-rs/code-mode/src/runtime/timers.rs b/codex-rs/code-mode/src/runtime/timers.rs new file mode 100644 index 0000000000..01c414cefe --- /dev/null +++ b/codex-rs/code-mode/src/runtime/timers.rs @@ -0,0 +1,114 @@ +use std::thread; +use std::time::Duration; + +use super::RuntimeCommand; +use super::RuntimeState; +use super::value::value_to_error_text; + +pub(super) struct ScheduledTimeout { + callback: v8::Global, +} + +pub(super) fn schedule_timeout( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, +) -> Result { + let callback = args.get(0); + if !callback.is_function() { + return Err("setTimeout expects a function callback".to_string()); + } + let callback = v8::Local::::try_from(callback) + .map_err(|_| "setTimeout expects a function callback".to_string())?; + + let delay_ms = args + .get(1) + .number_value(scope) + .map(normalize_delay_ms) + .unwrap_or(0); + + let callback = v8::Global::new(scope, callback); + let state = scope + .get_slot_mut::() + .ok_or_else(|| "runtime state unavailable".to_string())?; + let timeout_id = state.next_timeout_id; + state.next_timeout_id = state.next_timeout_id.saturating_add(1); + let runtime_command_tx = state.runtime_command_tx.clone(); + state + .pending_timeouts + .insert(timeout_id, ScheduledTimeout { callback }); + thread::spawn(move || { + thread::sleep(Duration::from_millis(delay_ms)); + let _ = runtime_command_tx.send(RuntimeCommand::TimeoutFired { id: timeout_id }); + }); + + Ok(timeout_id) +} + +pub(super) fn clear_timeout( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, +) -> Result<(), String> { + let Some(timeout_id) = timeout_id_from_args(scope, args)? else { + return Ok(()); + }; + + let Some(state) = scope.get_slot_mut::() else { + return Err("runtime state unavailable".to_string()); + }; + state.pending_timeouts.remove(&timeout_id); + Ok(()) +} + +pub(super) fn invoke_timeout_callback( + scope: &mut v8::PinScope<'_, '_>, + timeout_id: u64, +) -> Result<(), String> { + let callback = { + let state = scope + .get_slot_mut::() + .ok_or_else(|| "runtime state unavailable".to_string())?; + state.pending_timeouts.remove(&timeout_id) + }; + let Some(callback) = callback else { + return Ok(()); + }; + + let tc = std::pin::pin!(v8::TryCatch::new(scope)); + let mut tc = tc.init(); + let callback = v8::Local::new(&tc, &callback.callback); + let receiver = v8::undefined(&tc).into(); + let _ = callback.call(&tc, receiver, &[]); + if tc.has_caught() { + return Err(tc + .exception() + .map(|exception| value_to_error_text(&mut tc, exception)) + .unwrap_or_else(|| "unknown code mode exception".to_string())); + } + + Ok(()) +} +fn timeout_id_from_args( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, +) -> Result, String> { + if args.length() == 0 || args.get(0).is_null_or_undefined() { + return Ok(None); + } + + let Some(timeout_id) = args.get(0).number_value(scope) else { + return Err("clearTimeout expects a numeric timeout id".to_string()); + }; + if !timeout_id.is_finite() || timeout_id <= 0.0 { + return Ok(None); + } + + Ok(Some(timeout_id.trunc().min(u64::MAX as f64) as u64)) +} + +fn normalize_delay_ms(delay_ms: f64) -> u64 { + if !delay_ms.is_finite() || delay_ms <= 0.0 { + 0 + } else { + delay_ms.trunc().min(u64::MAX as f64) as u64 + } +} diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode/src/service.rs index 260b891d36..5b67dd17b8 100644 --- a/codex-rs/code-mode/src/service.rs +++ b/codex-rs/code-mode/src/service.rs @@ -631,7 +631,7 @@ text(JSON.stringify(returnsUndefined)); event_rx, control_rx, initial_response_tx, - 60_000, + /*initial_yield_time_ms*/ 60_000, )); event_tx.send(RuntimeEvent::Started).unwrap(); diff --git a/codex-rs/codex-api/Cargo.toml b/codex-rs/codex-api/Cargo.toml index a3a5fa4d92..7bafd15328 100644 --- a/codex-rs/codex-api/Cargo.toml +++ b/codex-rs/codex-api/Cargo.toml @@ -6,7 +6,9 @@ license.workspace = true [dependencies] async-trait = { workspace = true } +base64 = { workspace = true } bytes = { workspace = true } +chrono = { workspace = true } codex-client = { workspace = true } codex-protocol = { workspace = true } codex-utils-rustls-provider = { workspace = true } diff --git a/codex-rs/core/src/api_bridge.rs b/codex-rs/codex-api/src/api_bridge.rs similarity index 81% rename from codex-rs/core/src/api_bridge.rs rename to codex-rs/codex-api/src/api_bridge.rs index 2060b78cf7..0ad2b13979 100644 --- a/codex-rs/core/src/api_bridge.rs +++ b/codex-rs/codex-api/src/api_bridge.rs @@ -1,24 +1,21 @@ +use crate::AuthProvider as ApiAuthProvider; +use crate::TransportError; +use crate::error::ApiError; +use crate::rate_limits::parse_promo_message; +use crate::rate_limits::parse_rate_limit_for_limit; use base64::Engine; use chrono::DateTime; use chrono::Utc; -use codex_api::AuthProvider as ApiAuthProvider; -use codex_api::TransportError; -use codex_api::error::ApiError; -use codex_api::rate_limits::parse_promo_message; -use codex_api::rate_limits::parse_rate_limit_for_limit; +use codex_protocol::auth::PlanType; +use codex_protocol::error::CodexErr; +use codex_protocol::error::RetryLimitReachedError; +use codex_protocol::error::UnexpectedResponseError; +use codex_protocol::error::UsageLimitReachedError; use http::HeaderMap; use serde::Deserialize; use serde_json::Value; -use crate::auth::CodexAuth; -use crate::error::CodexErr; -use crate::error::RetryLimitReachedError; -use crate::error::UnexpectedResponseError; -use crate::error::UsageLimitReachedError; -use crate::model_provider_info::ModelProviderInfo; -use crate::token_data::PlanType; - -pub(crate) fn map_api_error(err: ApiError) -> CodexErr { +pub fn map_api_error(err: ApiError) -> CodexErr { match err { ApiError::ContextWindowExceeded => CodexErr::ContextWindowExceeded, ApiError::QuotaExceeded => CodexErr::QuotaExceeded, @@ -164,38 +161,6 @@ fn extract_x_error_json_code(headers: Option<&HeaderMap>) -> Option { .map(str::to_string) } -pub(crate) fn auth_provider_from_auth( - auth: Option, - provider: &ModelProviderInfo, -) -> crate::error::Result { - if let Some(api_key) = provider.api_key()? { - return Ok(CoreAuthProvider { - token: Some(api_key), - account_id: None, - }); - } - - if let Some(token) = provider.experimental_bearer_token.clone() { - return Ok(CoreAuthProvider { - token: Some(token), - account_id: None, - }); - } - - if let Some(auth) = auth { - let token = auth.get_token()?; - Ok(CoreAuthProvider { - token: Some(token), - account_id: auth.get_account_id(), - }) - } else { - Ok(CoreAuthProvider { - token: None, - account_id: None, - }) - } -} - #[derive(Debug, Deserialize)] struct UsageErrorResponse { error: UsageErrorBody, @@ -210,24 +175,23 @@ struct UsageErrorBody { } #[derive(Clone, Default)] -pub(crate) struct CoreAuthProvider { - token: Option, - account_id: Option, +pub struct CoreAuthProvider { + pub token: Option, + pub account_id: Option, } impl CoreAuthProvider { - pub(crate) fn auth_header_attached(&self) -> bool { + pub fn auth_header_attached(&self) -> bool { self.token .as_ref() .is_some_and(|token| http::HeaderValue::from_str(&format!("Bearer {token}")).is_ok()) } - pub(crate) fn auth_header_name(&self) -> Option<&'static str> { + pub fn auth_header_name(&self) -> Option<&'static str> { self.auth_header_attached().then_some("authorization") } - #[cfg(test)] - pub(crate) fn for_test(token: Option<&str>, account_id: Option<&str>) -> Self { + pub fn for_test(token: Option<&str>, account_id: Option<&str>) -> Self { Self { token: token.map(str::to_string), account_id: account_id.map(str::to_string), diff --git a/codex-rs/core/src/api_bridge_tests.rs b/codex-rs/codex-api/src/api_bridge_tests.rs similarity index 100% rename from codex-rs/core/src/api_bridge_tests.rs rename to codex-rs/codex-api/src/api_bridge_tests.rs diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 39fb976e67..6a88cd1f47 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -153,7 +153,8 @@ impl From for OpenAiVerbosity { #[derive(Debug, Serialize, Clone, PartialEq)] pub struct ResponsesApiRequest { pub model: String, - pub instructions: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, pub input: Vec, pub tools: Vec, pub tool_choice: String, @@ -168,6 +169,8 @@ pub struct ResponsesApiRequest { pub prompt_cache_key: Option, #[serde(skip_serializing_if = "Option::is_none")] pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_metadata: Option>, } impl From<&ResponsesApiRequest> for ResponseCreateWsRequest { @@ -188,7 +191,7 @@ impl From<&ResponsesApiRequest> for ResponseCreateWsRequest { prompt_cache_key: request.prompt_cache_key.clone(), text: request.text.clone(), generate: None, - client_metadata: None, + client_metadata: request.client_metadata.clone(), } } } @@ -196,7 +199,8 @@ impl From<&ResponsesApiRequest> for ResponseCreateWsRequest { #[derive(Debug, Serialize)] pub struct ResponseCreateWsRequest { pub model: String, - pub instructions: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, #[serde(skip_serializing_if = "Option::is_none")] pub previous_response_id: Option, pub input: Vec, diff --git a/codex-rs/codex-api/src/endpoint/mod.rs b/codex-rs/codex-api/src/endpoint/mod.rs index 6a748e533d..a6c2603f70 100644 --- a/codex-rs/codex-api/src/endpoint/mod.rs +++ b/codex-rs/codex-api/src/endpoint/mod.rs @@ -1,7 +1,22 @@ -pub mod compact; -pub mod memories; -pub mod models; -pub mod realtime_websocket; -pub mod responses; -pub mod responses_websocket; +pub(crate) mod compact; +pub(crate) mod memories; +pub(crate) mod models; +pub(crate) mod realtime_websocket; +pub(crate) mod responses; +pub(crate) mod responses_websocket; mod session; + +pub use compact::CompactClient; +pub use memories::MemoriesClient; +pub use models::ModelsClient; +pub use realtime_websocket::RealtimeEventParser; +pub use realtime_websocket::RealtimeSessionConfig; +pub use realtime_websocket::RealtimeSessionMode; +pub use realtime_websocket::RealtimeWebsocketClient; +pub use realtime_websocket::RealtimeWebsocketConnection; +pub use realtime_websocket::RealtimeWebsocketEvents; +pub use realtime_websocket::RealtimeWebsocketWriter; +pub use responses::ResponsesClient; +pub use responses::ResponsesOptions; +pub use responses_websocket::ResponsesWebsocketClient; +pub use responses_websocket::ResponsesWebsocketConnection; diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs index 10c72d72be..544c7ad7a5 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs @@ -625,9 +625,9 @@ fn normalize_realtime_path(url: &mut Url) { #[cfg(test)] mod tests { use super::*; - use crate::endpoint::realtime_websocket::protocol::RealtimeHandoffRequested; use crate::endpoint::realtime_websocket::protocol::RealtimeTranscriptDelta; use crate::endpoint::realtime_websocket::protocol::RealtimeTranscriptEntry; + use codex_protocol::protocol::RealtimeHandoffRequested; use codex_protocol::protocol::RealtimeInputAudioSpeechStarted; use codex_protocol::protocol::RealtimeResponseCancelled; use http::HeaderValue; @@ -967,8 +967,8 @@ mod tests { fn websocket_url_from_http_base_defaults_to_ws_path() { let url = websocket_url_from_api_url( "http://127.0.0.1:8011", - None, - None, + /*query_params*/ None, + /*model*/ None, RealtimeEventParser::V1, RealtimeSessionMode::Conversational, ) @@ -983,7 +983,7 @@ mod tests { fn websocket_url_from_ws_base_defaults_to_ws_path() { let url = websocket_url_from_api_url( "wss://example.com", - None, + /*query_params*/ None, Some("realtime-test-model"), RealtimeEventParser::V1, RealtimeSessionMode::Conversational, @@ -999,7 +999,7 @@ mod tests { fn websocket_url_from_v1_base_appends_realtime_path() { let url = websocket_url_from_api_url( "https://api.openai.com/v1", - None, + /*query_params*/ None, Some("snapshot"), RealtimeEventParser::V1, RealtimeSessionMode::Conversational, @@ -1015,7 +1015,7 @@ mod tests { fn websocket_url_from_nested_v1_base_appends_realtime_path() { let url = websocket_url_from_api_url( "https://example.com/openai/v1", - None, + /*query_params*/ None, Some("snapshot"), RealtimeEventParser::V1, RealtimeSessionMode::Conversational, @@ -1050,8 +1050,8 @@ mod tests { fn websocket_url_v1_ignores_transcription_mode() { let url = websocket_url_from_api_url( "https://example.com", - None, - None, + /*query_params*/ None, + /*model*/ None, RealtimeEventParser::V1, RealtimeSessionMode::Transcription, ) @@ -1085,8 +1085,8 @@ mod tests { fn websocket_url_omits_intent_for_realtime_v2_transcription_mode() { let url = websocket_url_from_api_url( "https://example.com", - None, - None, + /*query_params*/ None, + /*model*/ None, RealtimeEventParser::RealtimeV2, RealtimeSessionMode::Transcription, ) diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/mod.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/mod.rs index d13585034a..867ebdff65 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/mod.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/mod.rs @@ -1,14 +1,12 @@ -pub mod methods; +pub(crate) mod methods; mod methods_common; mod methods_v1; mod methods_v2; -pub mod protocol; +pub(crate) mod protocol; mod protocol_common; mod protocol_v1; mod protocol_v2; -pub use codex_protocol::protocol::RealtimeAudioFrame; -pub use codex_protocol::protocol::RealtimeEvent; pub use methods::RealtimeWebsocketClient; pub use methods::RealtimeWebsocketConnection; pub use methods::RealtimeWebsocketEvents; diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs index 2c629249fa..4941740556 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs @@ -2,7 +2,6 @@ use crate::endpoint::realtime_websocket::protocol_v1::parse_realtime_event_v1; use crate::endpoint::realtime_websocket::protocol_v2::parse_realtime_event_v2; pub use codex_protocol::protocol::RealtimeAudioFrame; pub use codex_protocol::protocol::RealtimeEvent; -pub use codex_protocol::protocol::RealtimeHandoffRequested; pub use codex_protocol::protocol::RealtimeTranscriptDelta; pub use codex_protocol::protocol::RealtimeTranscriptEntry; use serde::Serialize; diff --git a/codex-rs/codex-api/src/endpoint/responses.rs b/codex-rs/codex-api/src/endpoint/responses.rs index 57a44d2e27..8e0e7384fd 100644 --- a/codex-rs/codex-api/src/endpoint/responses.rs +++ b/codex-rs/codex-api/src/endpoint/responses.rs @@ -4,11 +4,11 @@ use crate::common::ResponsesApiRequest; use crate::endpoint::session::EndpointSession; use crate::error::ApiError; use crate::provider::Provider; +use crate::requests::Compression; +use crate::requests::attach_item_ids; use crate::requests::headers::build_conversation_headers; use crate::requests::headers::insert_header; use crate::requests::headers::subagent_header; -use crate::requests::responses::Compression; -use crate::requests::responses::attach_item_ids; use crate::sse::spawn_response_stream; use crate::telemetry::SseTelemetry; use codex_client::HttpTransport; diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index d3b578db69..a60d188bda 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -6,8 +6,8 @@ use crate::common::ResponsesWsRequest; use crate::error::ApiError; use crate::provider::Provider; use crate::rate_limits::parse_rate_limit_event; -use crate::sse::responses::ResponsesStreamEvent; -use crate::sse::responses::process_responses_event; +use crate::sse::ResponsesStreamEvent; +use crate::sse::process_responses_event; use crate::telemetry::WebsocketTelemetry; use codex_client::TransportError; use codex_client::maybe_build_rustls_client_config_with_custom_ca; diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index 865abf8a76..01a5637356 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -1,47 +1,58 @@ -pub mod auth; -pub mod common; -pub mod endpoint; -pub mod error; -pub mod provider; -pub mod rate_limits; -pub mod requests; -pub mod sse; -pub mod telemetry; +pub(crate) mod api_bridge; +pub(crate) mod auth; +pub(crate) mod common; +pub(crate) mod endpoint; +pub(crate) mod error; +pub(crate) mod provider; +pub(crate) mod rate_limits; +pub(crate) mod requests; +pub(crate) mod sse; +pub(crate) mod telemetry; pub use crate::requests::headers::build_conversation_headers; pub use codex_client::RequestTelemetry; pub use codex_client::ReqwestTransport; pub use codex_client::TransportError; +pub use crate::api_bridge::CoreAuthProvider; +pub use crate::api_bridge::map_api_error; pub use crate::auth::AuthProvider; pub use crate::common::CompactionInput; pub use crate::common::MemorySummarizeInput; pub use crate::common::MemorySummarizeOutput; +pub use crate::common::OpenAiVerbosity; pub use crate::common::RawMemory; pub use crate::common::RawMemoryMetadata; +pub use crate::common::Reasoning; pub use crate::common::ResponseCreateWsRequest; pub use crate::common::ResponseEvent; pub use crate::common::ResponseStream; pub use crate::common::ResponsesApiRequest; +pub use crate::common::ResponsesWsRequest; +pub use crate::common::TextControls; pub use crate::common::WS_REQUEST_HEADER_TRACEPARENT_CLIENT_METADATA_KEY; pub use crate::common::WS_REQUEST_HEADER_TRACESTATE_CLIENT_METADATA_KEY; pub use crate::common::create_text_param_for_request; pub use crate::common::response_create_client_metadata; -pub use crate::endpoint::compact::CompactClient; -pub use crate::endpoint::memories::MemoriesClient; -pub use crate::endpoint::models::ModelsClient; -pub use crate::endpoint::realtime_websocket::RealtimeEventParser; -pub use crate::endpoint::realtime_websocket::RealtimeSessionConfig; -pub use crate::endpoint::realtime_websocket::RealtimeSessionMode; -pub use crate::endpoint::realtime_websocket::RealtimeWebsocketClient; -pub use crate::endpoint::realtime_websocket::RealtimeWebsocketConnection; -pub use crate::endpoint::responses::ResponsesClient; -pub use crate::endpoint::responses::ResponsesOptions; -pub use crate::endpoint::responses_websocket::ResponsesWebsocketClient; -pub use crate::endpoint::responses_websocket::ResponsesWebsocketConnection; +pub use crate::endpoint::CompactClient; +pub use crate::endpoint::MemoriesClient; +pub use crate::endpoint::ModelsClient; +pub use crate::endpoint::RealtimeEventParser; +pub use crate::endpoint::RealtimeSessionConfig; +pub use crate::endpoint::RealtimeSessionMode; +pub use crate::endpoint::RealtimeWebsocketClient; +pub use crate::endpoint::RealtimeWebsocketConnection; +pub use crate::endpoint::RealtimeWebsocketEvents; +pub use crate::endpoint::RealtimeWebsocketWriter; +pub use crate::endpoint::ResponsesClient; +pub use crate::endpoint::ResponsesOptions; +pub use crate::endpoint::ResponsesWebsocketClient; +pub use crate::endpoint::ResponsesWebsocketConnection; pub use crate::error::ApiError; pub use crate::provider::Provider; +pub use crate::provider::RetryConfig; pub use crate::provider::is_azure_responses_wire_base_url; +pub use crate::requests::Compression; pub use crate::sse::stream_from_fixture; pub use crate::telemetry::SseTelemetry; pub use crate::telemetry::WebsocketTelemetry; diff --git a/codex-rs/codex-api/src/rate_limits.rs b/codex-rs/codex-api/src/rate_limits.rs index 730f94d209..1c71dc7502 100644 --- a/codex-rs/codex-api/src/rate_limits.rs +++ b/codex-rs/codex-api/src/rate_limits.rs @@ -277,7 +277,7 @@ mod tests { HeaderValue::from_static("1704069000"), ); - let snapshot = parse_rate_limit_for_limit(&headers, None).expect("snapshot"); + let snapshot = parse_rate_limit_for_limit(&headers, /*limit_id*/ None).expect("snapshot"); assert_eq!(snapshot.limit_id.as_deref(), Some("codex")); assert_eq!(snapshot.limit_name, None); let primary = snapshot.primary.expect("primary"); diff --git a/codex-rs/codex-api/src/requests/mod.rs b/codex-rs/codex-api/src/requests/mod.rs index 4e98bb8cac..1c357b2a61 100644 --- a/codex-rs/codex-api/src/requests/mod.rs +++ b/codex-rs/codex-api/src/requests/mod.rs @@ -1,2 +1,5 @@ pub(crate) mod headers; -pub mod responses; +pub(crate) mod responses; + +pub use responses::Compression; +pub(crate) use responses::attach_item_ids; diff --git a/codex-rs/codex-api/src/sse/mod.rs b/codex-rs/codex-api/src/sse/mod.rs index cb689afc1d..06b9855890 100644 --- a/codex-rs/codex-api/src/sse/mod.rs +++ b/codex-rs/codex-api/src/sse/mod.rs @@ -1,5 +1,6 @@ -pub mod responses; +pub(crate) mod responses; -pub use responses::process_sse; +pub(crate) use responses::ResponsesStreamEvent; +pub(crate) use responses::process_responses_event; pub use responses::spawn_response_stream; pub use responses::stream_from_fixture; diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 696fd2953e..bf6d755060 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -515,7 +515,12 @@ mod tests { let stream = ReaderStream::new(reader).map_err(|err| TransportError::Network(err.to_string())); let (tx, mut rx) = mpsc::channel::>(16); - tokio::spawn(process_sse(Box::pin(stream), tx, idle_timeout(), None)); + tokio::spawn(process_sse( + Box::pin(stream), + tx, + idle_timeout(), + /*telemetry*/ None, + )); let mut events = Vec::new(); while let Some(ev) = rx.recv().await { @@ -541,7 +546,12 @@ mod tests { let (tx, mut rx) = mpsc::channel::>(8); let stream = ReaderStream::new(std::io::Cursor::new(body)) .map_err(|err| TransportError::Network(err.to_string())); - tokio::spawn(process_sse(Box::pin(stream), tx, idle_timeout(), None)); + tokio::spawn(process_sse( + Box::pin(stream), + tx, + idle_timeout(), + /*telemetry*/ None, + )); let mut out = Vec::new(); while let Some(ev) = rx.recv().await { @@ -695,7 +705,12 @@ mod tests { let stream: ByteStream = Box::pin(stream); let (tx, mut rx) = mpsc::channel::>(8); - tokio::spawn(process_sse(stream, tx, idle_timeout(), None)); + tokio::spawn(process_sse( + stream, + tx, + idle_timeout(), + /*telemetry*/ None, + )); let events = tokio::time::timeout(Duration::from_millis(1000), async { let mut events = Vec::new(); @@ -894,7 +909,12 @@ mod tests { bytes: Box::pin(bytes), }; - let mut stream = spawn_response_stream(stream_response, idle_timeout(), None, None); + let mut stream = spawn_response_stream( + stream_response, + idle_timeout(), + /*telemetry*/ None, + /*turn_state*/ None, + ); let event = stream .rx_event .recv() diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 63a033dea3..2e8411dd81 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -6,11 +6,11 @@ use anyhow::Result; use async_trait::async_trait; use bytes::Bytes; use codex_api::AuthProvider; +use codex_api::Compression; use codex_api::Provider; use codex_api::ResponsesApiRequest; use codex_api::ResponsesClient; use codex_api::ResponsesOptions; -use codex_api::requests::responses::Compression; use codex_client::HttpTransport; use codex_client::Request; use codex_client::Response; @@ -126,7 +126,7 @@ fn provider(name: &str) -> Provider { base_url: "https://example.com/v1".to_string(), query_params: None, headers: HeaderMap::new(), - retry: codex_api::provider::RetryConfig { + retry: codex_api::RetryConfig { max_attempts: 1, base_delay: Duration::from_millis(1), retry_429: false, @@ -203,7 +203,12 @@ async fn responses_client_uses_responses_path() -> Result<()> { let body = serde_json::json!({ "echo": true }); let _stream = client - .stream(body, HeaderMap::new(), Compression::None, None) + .stream( + body, + HeaderMap::new(), + Compression::None, + /*turn_state*/ None, + ) .await?; let requests = state.take_stream_requests(); @@ -220,7 +225,12 @@ async fn streaming_client_adds_auth_headers() -> Result<()> { let body = serde_json::json!({ "model": "gpt-test" }); let _stream = client - .stream(body, HeaderMap::new(), Compression::None, None) + .stream( + body, + HeaderMap::new(), + Compression::None, + /*turn_state*/ None, + ) .await?; let requests = state.take_stream_requests(); @@ -256,7 +266,7 @@ async fn streaming_client_retries_on_transport_error() -> Result<()> { let request = ResponsesApiRequest { model: "gpt-test".into(), - instructions: "Say hi".into(), + instructions: Some("Say hi".into()), input: Vec::new(), tools: Vec::new(), tool_choice: "auto".into(), @@ -268,6 +278,7 @@ async fn streaming_client_retries_on_transport_error() -> Result<()> { service_tier: None, prompt_cache_key: None, text: None, + client_metadata: None, }; let client = ResponsesClient::new(transport.clone(), provider, NoAuth); @@ -292,7 +303,7 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> { let request = ResponsesApiRequest { model: "gpt-test".into(), - instructions: "Say hi".into(), + instructions: Some("Say hi".into()), input: vec![ResponseItem::Message { id: Some("msg_1".into()), role: "user".into(), @@ -310,6 +321,7 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> { service_tier: None, prompt_cache_key: None, text: None, + client_metadata: None, }; let mut extra_headers = HeaderMap::new(); diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 4167c877dc..c6b28fa71a 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -1,7 +1,7 @@ use codex_api::AuthProvider; use codex_api::ModelsClient; -use codex_api::provider::Provider; -use codex_api::provider::RetryConfig; +use codex_api::Provider; +use codex_api::RetryConfig; use codex_client::ReqwestTransport; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ConfigShellToolType; @@ -85,7 +85,7 @@ async fn models_client_hits_models_endpoint() { availability_nux: None, apply_patch_tool_type: None, web_search_tool_type: Default::default(), - truncation_policy: TruncationPolicyConfig::bytes(10_000), + truncation_policy: TruncationPolicyConfig::bytes(/*limit*/ 10_000), supports_parallel_tool_calls: false, supports_image_detail_original: false, context_window: Some(272_000), diff --git a/codex-rs/codex-api/tests/realtime_websocket_e2e.rs b/codex-rs/codex-api/tests/realtime_websocket_e2e.rs index 130ab6fd35..42c7a92e2d 100644 --- a/codex-rs/codex-api/tests/realtime_websocket_e2e.rs +++ b/codex-rs/codex-api/tests/realtime_websocket_e2e.rs @@ -2,14 +2,14 @@ use std::collections::HashMap; use std::future::Future; use std::time::Duration; +use codex_api::Provider; use codex_api::RealtimeAudioFrame; use codex_api::RealtimeEvent; use codex_api::RealtimeEventParser; use codex_api::RealtimeSessionConfig; use codex_api::RealtimeSessionMode; use codex_api::RealtimeWebsocketClient; -use codex_api::provider::Provider; -use codex_api::provider::RetryConfig; +use codex_api::RetryConfig; use codex_protocol::protocol::RealtimeHandoffRequested; use futures::SinkExt; use futures::StreamExt; 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 f625fdcd9d..b15de296a8 100644 --- a/codex-rs/codex-api/tests/sse_end_to_end.rs +++ b/codex-rs/codex-api/tests/sse_end_to_end.rs @@ -4,10 +4,10 @@ use anyhow::Result; use async_trait::async_trait; use bytes::Bytes; use codex_api::AuthProvider; +use codex_api::Compression; use codex_api::Provider; use codex_api::ResponseEvent; use codex_api::ResponsesClient; -use codex_api::requests::responses::Compression; use codex_client::HttpTransport; use codex_client::Request; use codex_client::Response; @@ -64,7 +64,7 @@ fn provider(name: &str) -> Provider { base_url: "https://example.com/v1".to_string(), query_params: None, headers: HeaderMap::new(), - retry: codex_api::provider::RetryConfig { + retry: codex_api::RetryConfig { max_attempts: 1, base_delay: Duration::from_millis(1), retry_429: false, @@ -125,7 +125,7 @@ async fn responses_stream_parses_items_and_completed_end_to_end() -> Result<()> serde_json::json!({"echo": true}), HeaderMap::new(), Compression::None, - None, + /*turn_state*/ None, ) .await?; diff --git a/codex-rs/codex-backend-openapi-models/Cargo.toml b/codex-rs/codex-backend-openapi-models/Cargo.toml index f9bad4a494..ed3a1043d6 100644 --- a/codex-rs/codex-backend-openapi-models/Cargo.toml +++ b/codex-rs/codex-backend-openapi-models/Cargo.toml @@ -8,6 +8,9 @@ license.workspace = true name = "codex_backend_openapi_models" path = "src/lib.rs" +[lints] +workspace = true + # Important: generated code often violates our workspace lints. # Allow unwrap/expect in this crate so the workspace builds cleanly # after models are regenerated. diff --git a/codex-rs/codex-backend-openapi-models/src/models/mod.rs b/codex-rs/codex-backend-openapi-models/src/models/mod.rs index 90bc058334..2140c83f91 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/mod.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/mod.rs @@ -4,41 +4,41 @@ // The process for this will change // Config -pub mod config_file_response; +pub(crate) mod config_file_response; pub use self::config_file_response::ConfigFileResponse; // Cloud Tasks -pub mod code_task_details_response; +pub(crate) mod code_task_details_response; pub use self::code_task_details_response::CodeTaskDetailsResponse; -pub mod task_response; +pub(crate) mod task_response; pub use self::task_response::TaskResponse; -pub mod external_pull_request_response; +pub(crate) mod external_pull_request_response; pub use self::external_pull_request_response::ExternalPullRequestResponse; -pub mod git_pull_request; +pub(crate) mod git_pull_request; pub use self::git_pull_request::GitPullRequest; -pub mod task_list_item; +pub(crate) mod task_list_item; pub use self::task_list_item::TaskListItem; -pub mod paginated_list_task_list_item_; +pub(crate) mod paginated_list_task_list_item_; pub use self::paginated_list_task_list_item_::PaginatedListTaskListItem; // Rate Limits -pub mod additional_rate_limit_details; +pub(crate) mod additional_rate_limit_details; pub use self::additional_rate_limit_details::AdditionalRateLimitDetails; -pub mod rate_limit_status_payload; +pub(crate) mod rate_limit_status_payload; pub use self::rate_limit_status_payload::PlanType; pub use self::rate_limit_status_payload::RateLimitStatusPayload; -pub mod rate_limit_status_details; +pub(crate) mod rate_limit_status_details; pub use self::rate_limit_status_details::RateLimitStatusDetails; -pub mod rate_limit_window_snapshot; +pub(crate) mod rate_limit_window_snapshot; pub use self::rate_limit_window_snapshot::RateLimitWindowSnapshot; -pub mod credit_status_details; +pub(crate) mod credit_status_details; pub use self::credit_status_details::CreditStatusDetails; diff --git a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs index 3d0492fa83..300674357a 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs @@ -69,8 +69,12 @@ pub enum PlanType { FreeWorkspace, #[serde(rename = "team")] Team, + #[serde(rename = "self_serve_business_usage_based")] + SelfServeBusinessUsageBased, #[serde(rename = "business")] Business, + #[serde(rename = "enterprise_cbp_usage_based")] + EnterpriseCbpUsageBased, #[serde(rename = "education")] Education, #[serde(rename = "quorum")] diff --git a/codex-rs/artifacts/BUILD.bazel b/codex-rs/codex-mcp/BUILD.bazel similarity index 50% rename from codex-rs/artifacts/BUILD.bazel rename to codex-rs/codex-mcp/BUILD.bazel index 300e9b0fc6..fbae63201a 100644 --- a/codex-rs/artifacts/BUILD.bazel +++ b/codex-rs/codex-mcp/BUILD.bazel @@ -1,6 +1,6 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( - name = "artifacts", - crate_name = "codex_artifacts", + name = "codex-mcp", + crate_name = "codex_mcp", ) diff --git a/codex-rs/codex-mcp/Cargo.toml b/codex-rs/codex-mcp/Cargo.toml new file mode 100644 index 0000000000..92aa584646 --- /dev/null +++ b/codex-rs/codex-mcp/Cargo.toml @@ -0,0 +1,40 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-mcp" +version.workspace = true + +[lib] +name = "codex_mcp" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +async-channel = { workspace = true } +codex-async-utils = { workspace = true } +codex-config = { workspace = true } +codex-login = { workspace = true } +codex-otel = { workspace = true } +codex-plugin = { workspace = true } +codex-protocol = { workspace = true } +codex-rmcp-client = { workspace = true } +codex-utils-plugins = { workspace = true } +futures = { workspace = true } +regex-lite = { workspace = true } +rmcp = { workspace = true, default-features = false, features = ["base64", "macros", "schemars", "server"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha1 = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tokio-util = { workspace = true, features = ["rt"] } +tracing = { workspace = true } +url = { workspace = true } + +[dev-dependencies] +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/lib.rs b/codex-rs/codex-mcp/src/lib.rs new file mode 100644 index 0000000000..48969841c4 --- /dev/null +++ b/codex-rs/codex-mcp/src/lib.rs @@ -0,0 +1,40 @@ +pub(crate) mod mcp; +pub(crate) mod mcp_connection_manager; + +pub use mcp::CODEX_APPS_MCP_SERVER_NAME; +pub use mcp::McpAuthStatusEntry; +pub use mcp::McpConfig; +pub use mcp::McpManager; +pub use mcp::McpOAuthLoginConfig; +pub use mcp::McpOAuthLoginSupport; +pub use mcp::McpOAuthScopesSource; +pub use mcp::McpSnapshotDetail; +pub use mcp::ResolvedMcpOAuthScopes; +pub use mcp::ToolPluginProvenance; +pub use mcp::canonical_mcp_server_key; +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::oauth_login_support; +pub use mcp::qualified_mcp_tool_name_prefix; +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_CAPABILITY; +pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD; +pub use mcp_connection_manager::McpConnectionManager; +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::filter_non_codex_apps_mcp_tools_only; diff --git a/codex-rs/core/src/mcp/auth.rs b/codex-rs/codex-mcp/src/mcp/auth.rs similarity index 92% rename from codex-rs/core/src/mcp/auth.rs rename to codex-rs/codex-mcp/src/mcp/auth.rs index 06ddbdd51e..01dc5e9b46 100644 --- a/codex-rs/core/src/mcp/auth.rs +++ b/codex-rs/codex-mcp/src/mcp/auth.rs @@ -1,16 +1,16 @@ use std::collections::HashMap; use anyhow::Result; +use codex_config::types::OAuthCredentialsStoreMode; use codex_protocol::protocol::McpAuthStatus; -use codex_rmcp_client::OAuthCredentialsStoreMode; use codex_rmcp_client::OAuthProviderError; use codex_rmcp_client::determine_streamable_http_auth_status; use codex_rmcp_client::discover_streamable_http_oauth; use futures::future::join_all; use tracing::warn; -use crate::config::types::McpServerConfig; -use crate::config::types::McpServerTransportConfig; +use codex_config::McpServerConfig; +use codex_config::McpServerTransportConfig; #[derive(Debug, Clone)] pub struct McpOAuthLoginConfig { @@ -209,7 +209,7 @@ mod tests { #[test] fn resolve_oauth_scopes_prefers_configured_over_discovered() { let resolved = resolve_oauth_scopes( - None, + /*explicit_scopes*/ None, Some(vec!["configured".to_string()]), Some(vec!["discovered".to_string()]), ); @@ -225,7 +225,11 @@ mod tests { #[test] fn resolve_oauth_scopes_uses_discovered_when_needed() { - let resolved = resolve_oauth_scopes(None, None, Some(vec!["discovered".to_string()])); + let resolved = resolve_oauth_scopes( + /*explicit_scopes*/ None, + /*configured_scopes*/ None, + Some(vec!["discovered".to_string()]), + ); assert_eq!( resolved, @@ -238,7 +242,11 @@ mod tests { #[test] fn resolve_oauth_scopes_preserves_explicitly_empty_configured_scopes() { - let resolved = resolve_oauth_scopes(None, Some(Vec::new()), Some(vec!["ignored".into()])); + let resolved = resolve_oauth_scopes( + /*explicit_scopes*/ None, + Some(Vec::new()), + Some(vec!["ignored".into()]), + ); assert_eq!( resolved, @@ -251,7 +259,10 @@ mod tests { #[test] fn resolve_oauth_scopes_falls_back_to_empty() { - let resolved = resolve_oauth_scopes(None, None, None); + let resolved = resolve_oauth_scopes( + /*explicit_scopes*/ None, /*configured_scopes*/ None, + /*discovered_scopes*/ None, + ); assert_eq!( resolved, diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs similarity index 63% rename from codex-rs/core/src/mcp/mod.rs rename to codex-rs/codex-mcp/src/mcp/mod.rs index 81ee0c7fe8..9301ff1008 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -1,38 +1,127 @@ -pub mod auth; +pub(crate) mod auth; mod skill_dependencies; -pub(crate) use skill_dependencies::maybe_prompt_and_install_mcp_dependencies; +pub use auth::McpAuthStatusEntry; +pub use auth::McpOAuthLoginConfig; +pub use auth::McpOAuthLoginSupport; +pub use auth::McpOAuthScopesSource; +pub use auth::ResolvedMcpOAuthScopes; +pub use auth::compute_auth_statuses; +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; use std::collections::HashMap; use std::env; use std::path::PathBuf; -use std::sync::Arc; use std::time::Duration; use async_channel::unbounded; +use codex_config::Constrained; +use codex_config::McpServerConfig; +use codex_config::McpServerTransportConfig; +use codex_config::types::OAuthCredentialsStoreMode; +use codex_login::CodexAuth; +use codex_plugin::PluginCapabilitySummary; use codex_protocol::mcp::Resource; use codex_protocol::mcp::ResourceTemplate; use codex_protocol::mcp::Tool; +use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::McpListToolsResponseEvent; use codex_protocol::protocol::SandboxPolicy; use serde_json::Value; -use crate::AuthManager; -use crate::CodexAuth; -use crate::config::Config; -use crate::config::types::McpServerConfig; -use crate::config::types::McpServerTransportConfig; -use crate::mcp::auth::compute_auth_statuses; use crate::mcp_connection_manager::McpConnectionManager; use crate::mcp_connection_manager::SandboxState; use crate::mcp_connection_manager::codex_apps_tools_cache_key; -use crate::plugins::PluginCapabilitySummary; -use crate::plugins::PluginsManager; +pub type McpManager = McpConnectionManager; const MCP_TOOL_NAME_PREFIX: &str = "mcp"; const MCP_TOOL_NAME_DELIMITER: &str = "__"; -pub(crate) const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps"; +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)] +pub enum McpSnapshotDetail { + #[default] + Full, + ToolsAndAuthOnly, +} + +impl McpSnapshotDetail { + fn include_resources(self) -> bool { + matches!(self, Self::Full) + } +} + +/// 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}" + )) +} + +/// MCP runtime settings derived from `codex_core::config::Config`. +/// +/// This struct should contain only long-lived configuration values that the +/// `codex-mcp` crate needs to construct server transports, enforce MCP +/// 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 +/// not go stale when auth changes. +#[derive(Debug, Clone)] +pub struct McpConfig { + /// Base URL for ChatGPT-hosted app MCP servers, copied from the root config. + pub chatgpt_base_url: String, + /// Codex home directory used for MCP OAuth state and app-tool cache files. + pub codex_home: PathBuf, + /// Preferred credential store for MCP OAuth tokens. + pub mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode, + /// Optional fixed localhost callback port for MCP OAuth login. + pub mcp_oauth_callback_port: Option, + /// Optional OAuth redirect URI override for MCP login. + pub mcp_oauth_callback_url: Option, + /// Whether skill MCP dependency installation prompts are enabled. + pub skill_mcp_dependency_install_enabled: bool, + /// Approval policy used for MCP tool calls and MCP elicitation requests. + pub approval_policy: Constrained, + /// Optional path to `codex-linux-sandbox` for sandboxed MCP tool execution. + pub codex_linux_sandbox_exe: Option, + /// Whether to use legacy Landlock behavior in the MCP sandbox state. + pub use_legacy_landlock: bool, + /// Whether the app MCP integration is enabled by config. + /// + /// ChatGPT auth is checked separately at runtime before the built-in apps + /// MCP server is added. + pub apps_enabled: bool, + /// User-configured and plugin-provided MCP servers keyed by server name. + pub configured_mcp_servers: HashMap, + /// Plugin metadata used to attribute MCP tools/connectors to plugin display names. + pub plugin_capability_summaries: Vec, +} + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ToolPluginProvenance { plugin_display_names_by_connector_id: HashMap>, @@ -147,11 +236,11 @@ fn codex_apps_mcp_url_for_base_url(base_url: &str) -> String { } } -pub(crate) fn codex_apps_mcp_url(config: &Config) -> String { +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: &Config, auth: Option<&CodexAuth>) -> McpServerConfig { +fn codex_apps_mcp_server_config(config: &McpConfig, auth: Option<&CodexAuth>) -> McpServerConfig { let bearer_token_env_var = codex_apps_mcp_bearer_token_env_var(); let http_headers = if bearer_token_env_var.is_some() { None @@ -176,16 +265,16 @@ fn codex_apps_mcp_server_config(config: &Config, auth: Option<&CodexAuth>) -> Mc disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), } } -pub(crate) fn with_codex_apps_mcp( +pub fn with_codex_apps_mcp( mut servers: HashMap, - connectors_enabled: bool, auth: Option<&CodexAuth>, - config: &Config, + config: &McpConfig, ) -> HashMap { - if connectors_enabled { + if config.apps_enabled && auth.is_some_and(CodexAuth::is_chatgpt_auth) { servers.insert( CODEX_APPS_MCP_SERVER_NAME.to_string(), codex_apps_mcp_server_config(config, auth), @@ -196,69 +285,38 @@ pub(crate) fn with_codex_apps_mcp( servers } -pub struct McpManager { - plugins_manager: Arc, +pub fn configured_mcp_servers(config: &McpConfig) -> HashMap { + config.configured_mcp_servers.clone() } -impl McpManager { - pub fn new(plugins_manager: Arc) -> Self { - Self { plugins_manager } - } - - pub fn configured_servers(&self, config: &Config) -> HashMap { - configured_mcp_servers(config, self.plugins_manager.as_ref()) - } - - pub fn effective_servers( - &self, - config: &Config, - auth: Option<&CodexAuth>, - ) -> HashMap { - effective_mcp_servers(config, auth, self.plugins_manager.as_ref()) - } - - pub fn tool_plugin_provenance(&self, config: &Config) -> ToolPluginProvenance { - let loaded_plugins = self.plugins_manager.plugins_for_config(config); - ToolPluginProvenance::from_capability_summaries(loaded_plugins.capability_summaries()) - } -} - -fn configured_mcp_servers( - config: &Config, - plugins_manager: &PluginsManager, -) -> HashMap { - let loaded_plugins = plugins_manager.plugins_for_config(config); - let mut servers = config.mcp_servers.get().clone(); - for (name, plugin_server) in loaded_plugins.effective_mcp_servers() { - servers.entry(name).or_insert(plugin_server); - } - servers -} - -fn effective_mcp_servers( - config: &Config, +pub fn effective_mcp_servers( + config: &McpConfig, auth: Option<&CodexAuth>, - plugins_manager: &PluginsManager, ) -> HashMap { - let servers = configured_mcp_servers(config, plugins_manager); - with_codex_apps_mcp( - servers, - config.features.apps_enabled_for_auth(auth), - auth, - config, - ) + let servers = configured_mcp_servers(config); + with_codex_apps_mcp(servers, auth, config) } -pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent { - let auth_manager = AuthManager::shared( - config.codex_home.clone(), - /*enable_codex_api_key_env*/ false, - config.cli_auth_credentials_store_mode, - ); - let auth = auth_manager.auth().await; - let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone()))); - let mcp_servers = mcp_manager.effective_servers(config, auth.as_ref()); - let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config); +pub fn tool_plugin_provenance(config: &McpConfig) -> ToolPluginProvenance { + ToolPluginProvenance::from_capability_summaries(&config.plugin_capability_summaries) +} + +pub async fn collect_mcp_snapshot( + config: &McpConfig, + auth: Option<&CodexAuth>, + submit_id: String, +) -> McpListToolsResponseEvent { + collect_mcp_snapshot_with_detail(config, auth, submit_id, McpSnapshotDetail::Full).await +} + +pub async fn collect_mcp_snapshot_with_detail( + config: &McpConfig, + auth: Option<&CodexAuth>, + submit_id: String, + 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(), @@ -279,24 +337,29 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent sandbox_policy: SandboxPolicy::new_read_only_policy(), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), sandbox_cwd: env::current_dir().unwrap_or_else(|_| PathBuf::from("/")), - use_legacy_landlock: config.features.use_legacy_landlock(), + use_legacy_landlock: config.use_legacy_landlock, }; let (mcp_connection_manager, cancel_token) = McpConnectionManager::new( &mcp_servers, config.mcp_oauth_credentials_store_mode, auth_status_entries.clone(), - &config.permissions.approval_policy, + &config.approval_policy, + submit_id, tx_event, sandbox_state, config.codex_home.clone(), - codex_apps_tools_cache_key(auth.as_ref()), + codex_apps_tools_cache_key(auth), tool_plugin_provenance, ) .await; - let snapshot = - collect_mcp_snapshot_from_manager(&mcp_connection_manager, auth_status_entries).await; + let snapshot = collect_mcp_snapshot_from_manager_with_detail( + &mcp_connection_manager, + auth_status_entries, + detail, + ) + .await; cancel_token.cancel(); @@ -332,20 +395,45 @@ pub fn group_tools_by_server( grouped } -pub(crate) async fn collect_mcp_snapshot_from_manager( +pub async fn collect_mcp_snapshot_from_manager( mcp_connection_manager: &McpConnectionManager, - auth_status_entries: HashMap, + 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( + mcp_connection_manager: &McpConnectionManager, + auth_status_entries: HashMap, + detail: McpSnapshotDetail, ) -> McpListToolsResponseEvent { let (tools, resources, resource_templates) = tokio::join!( mcp_connection_manager.list_all_tools(), - mcp_connection_manager.list_all_resources(), - mcp_connection_manager.list_all_resource_templates(), + async { + if detail.include_resources() { + mcp_connection_manager.list_all_resources().await + } else { + HashMap::new() + } + }, + async { + if detail.include_resources() { + mcp_connection_manager.list_all_resource_templates().await + } else { + HashMap::new() + } + }, ); let auth_statuses = auth_status_entries .iter() .map(|(name, entry)| (name.clone(), entry.auth_status)) - .collect(); + .collect::>(); let tools = tools .into_iter() @@ -362,7 +450,7 @@ pub(crate) async fn collect_mcp_snapshot_from_manager( None } }) - .collect(); + .collect::>(); let resources = resources .into_iter() @@ -397,7 +485,7 @@ pub(crate) async fn collect_mcp_snapshot_from_manager( .collect::>(); (name, resources) }) - .collect(); + .collect::>(); let resource_templates = resource_templates .into_iter() @@ -433,7 +521,7 @@ pub(crate) async fn collect_mcp_snapshot_from_manager( .collect::>(); (name, templates) }) - .collect(); + .collect::>(); McpListToolsResponseEvent { tools, diff --git a/codex-rs/core/src/mcp/mod_tests.rs b/codex-rs/codex-mcp/src/mcp/mod_tests.rs similarity index 64% rename from codex-rs/core/src/mcp/mod_tests.rs rename to codex-rs/codex-mcp/src/mcp/mod_tests.rs index b6f7f542a0..8dc29bbf98 100644 --- a/codex-rs/core/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -1,34 +1,28 @@ use super::*; -use crate::config::CONFIG_TOML_FILE; -use crate::config::ConfigBuilder; -use crate::plugins::AppConnectorId; -use crate::plugins::PluginCapabilitySummary; -use codex_features::Feature; +use codex_config::Constrained; +use codex_login::CodexAuth; +use codex_plugin::AppConnectorId; +use codex_plugin::PluginCapabilitySummary; +use codex_protocol::protocol::AskForApproval; use pretty_assertions::assert_eq; -use std::fs; -use std::path::Path; -use toml::Value; +use std::collections::HashMap; +use std::path::PathBuf; -fn write_file(path: &Path, contents: &str) { - fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap(); - fs::write(path, contents).unwrap(); -} - -fn plugin_config_toml() -> String { - let mut root = toml::map::Map::new(); - - let mut features = toml::map::Map::new(); - features.insert("plugins".to_string(), Value::Boolean(true)); - root.insert("features".to_string(), Value::Table(features)); - - let mut plugin = toml::map::Map::new(); - plugin.insert("enabled".to_string(), Value::Boolean(true)); - - let mut plugins = toml::map::Map::new(); - plugins.insert("sample@test".to_string(), Value::Table(plugin)); - root.insert("plugins".to_string(), Value::Table(plugins)); - - toml::to_string(&Value::Table(root)).expect("plugin test config should serialize") +fn test_mcp_config(codex_home: PathBuf) -> McpConfig { + McpConfig { + chatgpt_base_url: "https://chatgpt.com".to_string(), + codex_home, + mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode::default(), + mcp_oauth_callback_port: None, + mcp_oauth_callback_url: None, + skill_mcp_dependency_install_enabled: true, + approval_policy: Constrained::allow_any(AskForApproval::OnFailure), + codex_linux_sandbox_exe: None, + use_legacy_landlock: false, + apps_enabled: false, + configured_mcp_servers: HashMap::new(), + plugin_capability_summaries: Vec::new(), + } } fn make_tool(name: &str) -> Tool { @@ -52,6 +46,14 @@ fn split_qualified_tool_name_returns_server_and_tool() { ); } +#[test] +fn qualified_mcp_tool_name_prefix_sanitizes_server_names_without_lowercasing() { + assert_eq!( + qualified_mcp_tool_name_prefix("Some-Server"), + "mcp__Some_Server__".to_string() + ); +} + #[test] fn split_qualified_tool_name_rejects_invalid_names() { assert_eq!(split_qualified_tool_name("other__alpha__do_thing"), None); @@ -143,10 +145,9 @@ fn codex_apps_mcp_url_for_base_url_keeps_existing_paths() { ); } -#[tokio::test] -async fn codex_apps_mcp_url_uses_legacy_codex_apps_path() { - let mut config = crate::config::test_config().await; - config.chatgpt_base_url = "https://chatgpt.com".to_string(); +#[test] +fn codex_apps_mcp_url_uses_legacy_codex_apps_path() { + let config = test_mcp_config(PathBuf::from("/tmp")); assert_eq!( codex_apps_mcp_url(&config), @@ -154,20 +155,17 @@ async fn codex_apps_mcp_url_uses_legacy_codex_apps_path() { ); } -#[tokio::test] -async fn codex_apps_server_config_uses_legacy_codex_apps_path() { - let mut config = crate::config::test_config().await; - config.chatgpt_base_url = "https://chatgpt.com".to_string(); +#[test] +fn codex_apps_server_config_uses_legacy_codex_apps_path() { + let mut config = test_mcp_config(PathBuf::from("/tmp")); + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let mut servers = with_codex_apps_mcp(HashMap::new(), false, None, &config); + let mut servers = with_codex_apps_mcp(HashMap::new(), /*auth*/ None, &config); assert!(!servers.contains_key(CODEX_APPS_MCP_SERVER_NAME)); - config - .features - .enable(Feature::Apps) - .expect("test config should allow apps"); + config.apps_enabled = true; - servers = with_codex_apps_mcp(servers, true, None, &config); + servers = with_codex_apps_mcp(servers, Some(&auth), &config); let server = servers .get(CODEX_APPS_MCP_SERVER_NAME) .expect("codex apps should be present when apps is enabled"); @@ -180,44 +178,13 @@ async fn codex_apps_server_config_uses_legacy_codex_apps_path() { } #[tokio::test] -async fn effective_mcp_servers_include_plugins_without_overriding_user_config() { +async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() { let codex_home = tempfile::tempdir().expect("tempdir"); - let plugin_root = codex_home - .path() - .join("plugins/cache") - .join("test/sample/local"); - write_file( - &plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"sample"}"#, - ); - write_file( - &plugin_root.join(".mcp.json"), - r#"{ - "mcpServers": { - "sample": { - "type": "http", - "url": "https://plugin.example/mcp" - }, - "docs": { - "type": "http", - "url": "https://docs.example/mcp" - } - } -}"#, - ); - write_file( - &codex_home.path().join(CONFIG_TOML_FILE), - &plugin_config_toml(), - ); + let mut config = test_mcp_config(codex_home.path().to_path_buf()); + config.apps_enabled = true; + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("config should load"); - - let mut configured_servers = config.mcp_servers.get().clone(); - configured_servers.insert( + config.configured_mcp_servers.insert( "sample".to_string(), McpServerConfig { transport: McpServerTransportConfig::StreamableHttp { @@ -235,18 +202,40 @@ async fn effective_mcp_servers_include_plugins_without_overriding_user_config() disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), + }, + ); + config.configured_mcp_servers.insert( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://docs.example/mcp".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + tools: HashMap::new(), }, ); - config - .mcp_servers - .set(configured_servers) - .expect("test config should accept MCP servers"); - let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone()))); - let effective = mcp_manager.effective_servers(&config, None); + let effective = effective_mcp_servers(&config, Some(&auth)); let sample = effective.get("sample").expect("user server should exist"); - let docs = effective.get("docs").expect("plugin server should exist"); + let docs = effective + .get("docs") + .expect("configured server should exist"); + let codex_apps = effective + .get(CODEX_APPS_MCP_SERVER_NAME) + .expect("codex apps server should exist"); match &sample.transport { McpServerTransportConfig::StreamableHttp { url, .. } => { @@ -260,4 +249,10 @@ async fn effective_mcp_servers_include_plugins_without_overriding_user_config() } other => panic!("expected streamable http transport, got {other:?}"), } + match &codex_apps.transport { + McpServerTransportConfig::StreamableHttp { url, .. } => { + assert_eq!(url, "https://chatgpt.com/backend-api/wham/apps"); + } + other => panic!("expected streamable http transport, got {other:?}"), + } } diff --git a/codex-rs/codex-mcp/src/mcp/skill_dependencies.rs b/codex-rs/codex-mcp/src/mcp/skill_dependencies.rs new file mode 100644 index 0000000000..aa26fd20fe --- /dev/null +++ b/codex-rs/codex-mcp/src/mcp/skill_dependencies.rs @@ -0,0 +1,166 @@ +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, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: 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, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: 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/core/src/mcp/skill_dependencies_tests.rs b/codex-rs/codex-mcp/src/mcp/skill_dependencies_tests.rs similarity index 93% rename from codex-rs/core/src/mcp/skill_dependencies_tests.rs rename to codex-rs/codex-mcp/src/mcp/skill_dependencies_tests.rs index ebabe16f80..3f5f85d194 100644 --- a/codex-rs/core/src/mcp/skill_dependencies_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/skill_dependencies_tests.rs @@ -1,5 +1,6 @@ use super::*; -use crate::model::SkillDependencies; +use codex_protocol::protocol::SkillDependencies; +use codex_protocol::protocol::SkillMetadata; use codex_protocol::protocol::SkillScope; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -11,11 +12,9 @@ fn skill_with_tools(tools: Vec) -> SkillMetadata { short_description: None, interface: None, dependencies: Some(SkillDependencies { tools }), - policy: None, - permission_profile: None, - managed_network_override: None, - path_to_skills_md: PathBuf::from("skill"), + path: PathBuf::from("skill"), scope: SkillScope::User, + enabled: true, } } @@ -48,6 +47,7 @@ fn collect_missing_respects_canonical_installed_key() { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, )]); @@ -97,6 +97,7 @@ fn collect_missing_dedupes_by_canonical_key_but_preserves_original_name() { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, )]); diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/codex-mcp/src/mcp_connection_manager.rs similarity index 96% rename from codex-rs/core/src/mcp_connection_manager.rs rename to codex-rs/codex-mcp/src/mcp_connection_manager.rs index eac8485048..c90088c7dc 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/codex-mcp/src/mcp_connection_manager.rs @@ -19,9 +19,14 @@ 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::auth::McpAuthStatusEntry; +use crate::mcp::configured_mcp_servers; +use crate::mcp::effective_mcp_servers; +use crate::mcp::sanitize_responses_api_tool_name; +use crate::mcp::tool_plugin_provenance; use anyhow::Context; use anyhow::Result; use anyhow::anyhow; @@ -29,6 +34,7 @@ use async_channel::Sender; use codex_async_utils::CancelErr; use codex_async_utils::OrCancelExt; use codex_config::Constrained; +use codex_config::types::OAuthCredentialsStoreMode; use codex_protocol::approvals::ElicitationRequest; use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::mcp::CallToolResult; @@ -42,7 +48,6 @@ use codex_protocol::protocol::McpStartupStatus; use codex_protocol::protocol::McpStartupUpdateEvent; use codex_protocol::protocol::SandboxPolicy; use codex_rmcp_client::ElicitationResponse; -use codex_rmcp_client::OAuthCredentialsStoreMode; use codex_rmcp_client::RmcpClient; use codex_rmcp_client::SendElicitation; use futures::future::BoxFuture; @@ -78,11 +83,11 @@ use tracing::instrument; use tracing::warn; use url::Url; -use crate::codex::INITIAL_SUBMIT_ID; -use crate::config::types::McpServerConfig; -use crate::config::types::McpServerTransportConfig; -use crate::connectors::is_connector_id_allowed; -use crate::connectors::sanitize_name; +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 the server name from the tool name in a fully /// qualified tool name. @@ -93,7 +98,7 @@ const MCP_TOOL_NAME_DELIMITER: &str = "__"; const MAX_TOOL_NAME_LENGTH: usize = 64; /// Default timeout for initializing MCP server & initially listing tools. -pub const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(10); +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); @@ -104,26 +109,6 @@ 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"; -/// 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 `_`. -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 - } -} - fn sha1_hex(s: &str) -> String { let mut hasher = Sha1::new(); hasher.update(s.as_bytes()); @@ -131,9 +116,7 @@ fn sha1_hex(s: &str) -> String { format!("{sha1:x}") } -pub(crate) fn codex_apps_tools_cache_key( - auth: Option<&crate::CodexAuth>, -) -> CodexAppsToolsCacheKey { +pub fn codex_apps_tools_cache_key(auth: Option<&CodexAuth>) -> CodexAppsToolsCacheKey { let token_data = auth.and_then(|auth| auth.get_token_data().ok()); let account_id = token_data .as_ref() @@ -199,20 +182,20 @@ where } #[derive(Debug, Clone, Serialize, Deserialize)] -pub(crate) struct ToolInfo { - pub(crate) server_name: String, - pub(crate) tool_name: String, - pub(crate) tool_namespace: String, - pub(crate) tool: Tool, - pub(crate) connector_id: Option, - pub(crate) connector_name: Option, +pub struct ToolInfo { + pub server_name: String, + pub tool_name: String, + pub tool_namespace: String, + pub tool: Tool, + pub connector_id: Option, + pub connector_name: Option, #[serde(default)] - pub(crate) plugin_display_names: Vec, - pub(crate) connector_description: Option, + pub plugin_display_names: Vec, + pub connector_description: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct CodexAppsToolsCacheKey { +pub struct CodexAppsToolsCacheKey { account_id: Option, chatgpt_user_id: Option, is_workspace_account: bool, @@ -595,14 +578,30 @@ pub struct SandboxState { } /// A thin wrapper around a set of running [`RmcpClient`] instances. -pub(crate) struct McpConnectionManager { +pub struct McpConnectionManager { clients: HashMap, server_origins: HashMap, elicitation_requests: ElicitationRequestManager, } impl McpConnectionManager { - pub(crate) fn new_uninitialized(approval_policy: &Constrained) -> Self { + 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) -> Self { Self { clients: HashMap::new(), server_origins: HashMap::new(), @@ -610,18 +609,11 @@ impl McpConnectionManager { } } - #[cfg(test)] - pub(crate) fn new_mcp_connection_manager_for_tests( - approval_policy: &Constrained, - ) -> Self { - Self::new_uninitialized(approval_policy) - } - - pub(crate) fn has_servers(&self) -> bool { + pub fn has_servers(&self) -> bool { !self.clients.is_empty() } - pub(crate) fn server_origin(&self, server_name: &str) -> Option<&str> { + pub fn server_origin(&self, server_name: &str) -> Option<&str> { self.server_origins.get(server_name).map(String::as_str) } @@ -637,6 +629,7 @@ impl McpConnectionManager { store_mode: OAuthCredentialsStoreMode, auth_entries: HashMap, approval_policy: &Constrained, + submit_id: String, tx_event: Sender, initial_sandbox_state: SandboxState, codex_home: PathBuf, @@ -649,6 +642,7 @@ impl McpConnectionManager { let mut join_set = JoinSet::new(); let elicitation_requests = ElicitationRequestManager::new(approval_policy.value()); let tool_plugin_provenance = Arc::new(tool_plugin_provenance); + let startup_submit_id = submit_id.clone(); let mcp_servers = mcp_servers.clone(); for (server_name, cfg) in mcp_servers.into_iter().filter(|(_, cfg)| cfg.enabled) { if let Some(origin) = transport_origin(&cfg.transport) { @@ -656,6 +650,7 @@ impl McpConnectionManager { } let cancel_token = cancel_token.child_token(); let _ = emit_update( + startup_submit_id.as_str(), &tx_event, McpStartupUpdateEvent { server: server_name.clone(), @@ -683,6 +678,7 @@ impl McpConnectionManager { ); 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(); let sandbox_state = initial_sandbox_state.clone(); join_set.spawn(async move { @@ -714,6 +710,7 @@ impl McpConnectionManager { }; let _ = emit_update( + submit_id.as_str(), &tx_event, McpStartupUpdateEvent { server: server_name.clone(), @@ -747,7 +744,7 @@ impl McpConnectionManager { } let _ = tx_event .send(Event { - id: INITIAL_SUBMIT_ID.to_owned(), + id: startup_submit_id, msg: EventMsg::McpStartupComplete(summary), }) .await; @@ -775,7 +772,7 @@ impl McpConnectionManager { .await } - pub(crate) async fn wait_for_server_ready(&self, server_name: &str, timeout: Duration) -> bool { + 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; }; @@ -786,7 +783,7 @@ impl McpConnectionManager { } } - pub(crate) async fn required_startup_failures( + pub async fn required_startup_failures( &self, required_servers: &[String], ) -> Vec { @@ -1132,12 +1129,13 @@ impl McpConnectionManager { } async fn emit_update( + submit_id: &str, tx_event: &Sender, update: McpStartupUpdateEvent, ) -> Result<(), async_channel::SendError> { tx_event .send(Event { - id: INITIAL_SUBMIT_ID.to_owned(), + id: submit_id.to_string(), msg: EventMsg::McpStartupUpdate(update), }) .await @@ -1185,7 +1183,7 @@ fn filter_tools(tools: Vec, filter: &ToolFilter) -> Vec { .collect() } -pub(crate) fn filter_non_codex_apps_mcp_tools_only( +pub fn filter_non_codex_apps_mcp_tools_only( mcp_tools: &HashMap, ) -> HashMap { mcp_tools @@ -1570,7 +1568,7 @@ fn filter_disallowed_codex_apps_tools(tools: Vec) -> Vec { } fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &str)]) { - if let Some(metrics) = codex_otel::metrics::global() { + if let Some(metrics) = codex_otel::global() { let _ = metrics.record_duration(metric, duration, tags); } } diff --git a/codex-rs/core/src/mcp_connection_manager_tests.rs b/codex-rs/codex-mcp/src/mcp_connection_manager_tests.rs similarity index 98% rename from codex-rs/core/src/mcp_connection_manager_tests.rs rename to codex-rs/codex-mcp/src/mcp_connection_manager_tests.rs index c5f7fc4a40..b34a5cc043 100644 --- a/codex-rs/core/src/mcp_connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/mcp_connection_manager_tests.rs @@ -542,6 +542,7 @@ fn mcp_init_error_display_prompts_for_github_pat() { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, auth_status: McpAuthStatus::Unsupported, }; @@ -561,7 +562,7 @@ fn mcp_init_error_display_prompts_for_login_when_auth_required() { let server_name = "example"; let err: StartupOutcomeError = anyhow::anyhow!("Auth required for server").into(); - let display = mcp_init_error_display(server_name, None, &err); + let display = mcp_init_error_display(server_name, /*entry*/ None, &err); let expected = format!( "The {server_name} MCP server is not logged in. Run `codex mcp login {server_name}`." @@ -590,6 +591,7 @@ fn mcp_init_error_display_reports_generic_errors() { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, auth_status: McpAuthStatus::Unsupported, }; @@ -607,10 +609,10 @@ fn mcp_init_error_display_includes_startup_timeout_hint() { let server_name = "slow"; let err: StartupOutcomeError = anyhow::anyhow!("request timed out").into(); - let display = mcp_init_error_display(server_name, None, &err); + let display = mcp_init_error_display(server_name, /*entry*/ None, &err); assert_eq!( - "MCP client for `slow` timed out after 10 seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.slow]\nstartup_timeout_sec = XX", + "MCP client for `slow` timed out after 30 seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.slow]\nstartup_timeout_sec = XX", display ); } diff --git a/codex-rs/collaboration-mode-templates/BUILD.bazel b/codex-rs/collaboration-mode-templates/BUILD.bazel new file mode 100644 index 0000000000..0fbc86ec83 --- /dev/null +++ b/codex-rs/collaboration-mode-templates/BUILD.bazel @@ -0,0 +1,12 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "collaboration-mode-templates", + crate_name = "codex_collaboration_mode_templates", + compile_data = glob(["templates/*.md"]), +) + +exports_files( + glob(["templates/*.md"]), + visibility = ["//visibility:public"], +) diff --git a/codex-rs/collaboration-mode-templates/Cargo.toml b/codex-rs/collaboration-mode-templates/Cargo.toml new file mode 100644 index 0000000000..18c4e99822 --- /dev/null +++ b/codex-rs/collaboration-mode-templates/Cargo.toml @@ -0,0 +1,13 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-collaboration-mode-templates" +version.workspace = true + +[lib] +doctest = false +name = "codex_collaboration_mode_templates" +path = "src/lib.rs" + +[lints] +workspace = true diff --git a/codex-rs/collaboration-mode-templates/src/lib.rs b/codex-rs/collaboration-mode-templates/src/lib.rs new file mode 100644 index 0000000000..345990db0b --- /dev/null +++ b/codex-rs/collaboration-mode-templates/src/lib.rs @@ -0,0 +1,4 @@ +pub const PLAN: &str = include_str!("../templates/plan.md"); +pub const DEFAULT: &str = include_str!("../templates/default.md"); +pub const EXECUTE: &str = include_str!("../templates/execute.md"); +pub const PAIR_PROGRAMMING: &str = include_str!("../templates/pair_programming.md"); diff --git a/codex-rs/core/templates/collaboration_mode/default.md b/codex-rs/collaboration-mode-templates/templates/default.md similarity index 100% rename from codex-rs/core/templates/collaboration_mode/default.md rename to codex-rs/collaboration-mode-templates/templates/default.md diff --git a/codex-rs/core/templates/collaboration_mode/execute.md b/codex-rs/collaboration-mode-templates/templates/execute.md similarity index 100% rename from codex-rs/core/templates/collaboration_mode/execute.md rename to codex-rs/collaboration-mode-templates/templates/execute.md diff --git a/codex-rs/core/templates/collaboration_mode/pair_programming.md b/codex-rs/collaboration-mode-templates/templates/pair_programming.md similarity index 100% rename from codex-rs/core/templates/collaboration_mode/pair_programming.md rename to codex-rs/collaboration-mode-templates/templates/pair_programming.md diff --git a/codex-rs/core/templates/collaboration_mode/plan.md b/codex-rs/collaboration-mode-templates/templates/plan.md similarity index 100% rename from codex-rs/core/templates/collaboration_mode/plan.md rename to codex-rs/collaboration-mode-templates/templates/plan.md diff --git a/codex-rs/config/Cargo.toml b/codex-rs/config/Cargo.toml index f1bd2dba16..9532d74d00 100644 --- a/codex-rs/config/Cargo.toml +++ b/codex-rs/config/Cargo.toml @@ -8,10 +8,16 @@ license.workspace = true workspace = true [dependencies] +anyhow = { workspace = true } codex-app-server-protocol = { 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 } +dunce = { workspace = true } futures = { workspace = true, features = ["alloc", "std"] } multimap = { workspace = true } schemars = { workspace = true } @@ -24,8 +30,9 @@ tokio = { workspace = true, features = ["fs"] } toml = { workspace = true } toml_edit = { workspace = true } tracing = { workspace = true } +wildmatch = { workspace = true } [dev-dependencies] -anyhow = { workspace = true } pretty_assertions = { workspace = true } +tempfile = { workspace = true } tokio = { workspace = true, features = ["full"] } diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 57d762c0f1..eb1c1033a5 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -1,3 +1,4 @@ +use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::WebSearchMode; use codex_protocol::protocol::AskForApproval; @@ -5,6 +6,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde::Serialize; +use serde::de::Error as _; use std::collections::BTreeMap; use std::fmt; @@ -77,6 +79,7 @@ impl std::ops::DerefMut for ConstrainedWithSource { #[derive(Debug, Clone, PartialEq)] pub struct ConfigRequirements { pub approval_policy: ConstrainedWithSource, + pub approvals_reviewer: ConstrainedWithSource, pub sandbox_policy: ConstrainedWithSource, pub web_search_mode: ConstrainedWithSource, pub feature_requirements: Option>, @@ -94,6 +97,10 @@ impl Default for ConfigRequirements { Constrained::allow_any_from_default(), /*source*/ None, ), + approvals_reviewer: ConstrainedWithSource::new( + Constrained::allow_any_from_default(), + /*source*/ None, + ), sandbox_policy: ConstrainedWithSource::new( Constrained::allow_any(SandboxPolicy::new_read_only_policy()), /*source*/ None, @@ -132,7 +139,93 @@ pub struct McpServerRequirement { pub identity: McpServerIdentity, } -#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct NetworkDomainPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl NetworkDomainPermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn allowed_domains(&self) -> Option> { + let allowed_domains: Vec = self + .entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Allow)) + .map(|(pattern, _)| pattern.clone()) + .collect(); + (!allowed_domains.is_empty()).then_some(allowed_domains) + } + + pub fn denied_domains(&self) -> Option> { + let denied_domains: Vec = self + .entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Deny)) + .map(|(pattern, _)| pattern.clone()) + .collect(); + (!denied_domains.is_empty()).then_some(denied_domains) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +pub enum NetworkDomainPermissionToml { + Allow, + Deny, +} + +impl std::fmt::Display for NetworkDomainPermissionToml { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let permission = match self { + Self::Allow => "allow", + Self::Deny => "deny", + }; + f.write_str(permission) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct NetworkUnixSocketPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl NetworkUnixSocketPermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn allow_unix_sockets(&self) -> Vec { + self.entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkUnixSocketPermissionToml::Allow)) + .map(|(path, _)| path.clone()) + .collect() + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +pub enum NetworkUnixSocketPermissionToml { + Allow, + None, +} + +impl std::fmt::Display for NetworkUnixSocketPermissionToml { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let permission = match self { + Self::Allow => "allow", + Self::None => "none", + }; + f.write_str(permission) + } +} + +#[derive(Serialize, Debug, Clone, Default, PartialEq, Eq)] pub struct NetworkRequirementsToml { pub enabled: Option, pub http_port: Option, @@ -140,17 +233,127 @@ pub struct NetworkRequirementsToml { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_all_unix_sockets: Option, - pub allowed_domains: Option>, + pub domains: Option, /// When true, only managed `allowed_domains` are respected while managed /// network enforcement is active. User allowlist entries are ignored. pub managed_allowed_domains_only: Option, - pub denied_domains: Option>, - pub allow_unix_sockets: Option>, + /// In danger-full-access mode, allow all network access and enforce managed deny entries. + pub danger_full_access_denylist_only: Option, + pub unix_sockets: Option, pub allow_local_binding: Option, } +#[derive(Deserialize)] +struct RawNetworkRequirementsToml { + enabled: Option, + http_port: Option, + socks_port: Option, + allow_upstream_proxy: Option, + dangerously_allow_non_loopback_proxy: Option, + dangerously_allow_all_unix_sockets: Option, + domains: Option, + #[serde(default)] + allowed_domains: Option>, + /// When true, only managed `allowed_domains` are respected while managed + /// network enforcement is active. User allowlist entries are ignored. + managed_allowed_domains_only: Option, + /// In danger-full-access mode, allow all network access and enforce managed deny entries. + danger_full_access_denylist_only: Option, + #[serde(default)] + denied_domains: Option>, + unix_sockets: Option, + #[serde(default)] + allow_unix_sockets: Option>, + allow_local_binding: Option, +} + +impl<'de> Deserialize<'de> for NetworkRequirementsToml { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = RawNetworkRequirementsToml::deserialize(deserializer)?; + let RawNetworkRequirementsToml { + enabled, + http_port, + socks_port, + allow_upstream_proxy, + dangerously_allow_non_loopback_proxy, + dangerously_allow_all_unix_sockets, + domains, + allowed_domains, + managed_allowed_domains_only, + danger_full_access_denylist_only, + denied_domains, + unix_sockets, + allow_unix_sockets, + allow_local_binding, + } = raw; + + if domains.is_some() && (allowed_domains.is_some() || denied_domains.is_some()) { + return Err(D::Error::custom( + "`experimental_network.domains` cannot be combined with legacy `allowed_domains` or `denied_domains`", + )); + } + + if unix_sockets.is_some() && allow_unix_sockets.is_some() { + return Err(D::Error::custom( + "`experimental_network.unix_sockets` cannot be combined with legacy `allow_unix_sockets`", + )); + } + + Ok(Self { + enabled, + http_port, + socks_port, + allow_upstream_proxy, + dangerously_allow_non_loopback_proxy, + dangerously_allow_all_unix_sockets, + domains: domains + .or_else(|| legacy_domain_permissions_from_lists(allowed_domains, denied_domains)), + managed_allowed_domains_only, + danger_full_access_denylist_only, + unix_sockets: unix_sockets + .or_else(|| legacy_unix_socket_permissions_from_list(allow_unix_sockets)), + allow_local_binding, + }) + } +} + +/// Legacy list normalization is intentionally lossy: explicit empty legacy +/// lists are treated as unset when converted to the canonical network +/// permission shape. +fn legacy_domain_permissions_from_lists( + allowed_domains: Option>, + denied_domains: Option>, +) -> Option { + let mut entries = BTreeMap::new(); + + for pattern in allowed_domains.unwrap_or_default() { + entries.insert(pattern, NetworkDomainPermissionToml::Allow); + } + + for pattern in denied_domains.unwrap_or_default() { + entries.insert(pattern, NetworkDomainPermissionToml::Deny); + } + + (!entries.is_empty()).then_some(NetworkDomainPermissionsToml { entries }) +} + +fn legacy_unix_socket_permissions_from_list( + allow_unix_sockets: Option>, +) -> Option { + let entries = allow_unix_sockets + .unwrap_or_default() + .into_iter() + .map(|path| (path, NetworkUnixSocketPermissionToml::Allow)) + .collect::>(); + + (!entries.is_empty()).then_some(NetworkUnixSocketPermissionsToml { entries }) +} + /// Normalized network constraints derived from requirements TOML. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct NetworkConstraints { pub enabled: Option, pub http_port: Option, @@ -158,15 +361,26 @@ pub struct NetworkConstraints { pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_all_unix_sockets: Option, - pub allowed_domains: Option>, + pub domains: Option, /// When true, only managed `allowed_domains` are respected while managed /// network enforcement is active. User allowlist entries are ignored. pub managed_allowed_domains_only: Option, - pub denied_domains: Option>, - pub allow_unix_sockets: Option>, + /// In danger-full-access mode, allow all network access and enforce managed deny entries. + pub danger_full_access_denylist_only: Option, + pub unix_sockets: Option, pub allow_local_binding: Option, } +impl<'de> Deserialize<'de> for NetworkConstraints { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let requirements = NetworkRequirementsToml::deserialize(deserializer)?; + Ok(requirements.into()) + } +} + impl From for NetworkConstraints { fn from(value: NetworkRequirementsToml) -> Self { let NetworkRequirementsToml { @@ -176,10 +390,10 @@ impl From for NetworkConstraints { allow_upstream_proxy, dangerously_allow_non_loopback_proxy, dangerously_allow_all_unix_sockets, - allowed_domains, + domains, managed_allowed_domains_only, - denied_domains, - allow_unix_sockets, + danger_full_access_denylist_only, + unix_sockets, allow_local_binding, } = value; Self { @@ -189,10 +403,10 @@ impl From for NetworkConstraints { allow_upstream_proxy, dangerously_allow_non_loopback_proxy, dangerously_allow_all_unix_sockets, - allowed_domains, + domains, managed_allowed_domains_only, - denied_domains, - allow_unix_sockets, + danger_full_access_denylist_only, + unix_sockets, allow_local_binding, } } @@ -289,6 +503,7 @@ pub(crate) fn merge_enablement_settings_descending( #[derive(Deserialize, Debug, Clone, Default, PartialEq)] pub struct ConfigRequirementsToml { pub allowed_approval_policies: Option>, + pub allowed_approvals_reviewers: Option>, pub allowed_sandbox_modes: Option>, pub allowed_web_search_modes: Option>, #[serde(rename = "features", alias = "feature_requirements")] @@ -327,6 +542,7 @@ impl std::ops::Deref for Sourced { #[derive(Debug, Clone, Default, PartialEq)] pub struct ConfigRequirementsWithSources { pub allowed_approval_policies: Option>>, + pub allowed_approvals_reviewers: Option>>, pub allowed_sandbox_modes: Option>>, pub allowed_web_search_modes: Option>>, pub feature_requirements: Option>, @@ -358,6 +574,7 @@ impl ConfigRequirementsWithSources { // forces this merge logic to be updated. let ConfigRequirementsToml { allowed_approval_policies: _, + allowed_approvals_reviewers: _, allowed_sandbox_modes: _, allowed_web_search_modes: _, feature_requirements: _, @@ -383,6 +600,7 @@ impl ConfigRequirementsWithSources { source, { allowed_approval_policies, + allowed_approvals_reviewers, allowed_sandbox_modes, allowed_web_search_modes, feature_requirements, @@ -406,6 +624,7 @@ impl ConfigRequirementsWithSources { pub fn into_toml(self) -> ConfigRequirementsToml { let ConfigRequirementsWithSources { allowed_approval_policies, + allowed_approvals_reviewers, allowed_sandbox_modes, allowed_web_search_modes, feature_requirements, @@ -418,6 +637,7 @@ impl ConfigRequirementsWithSources { } = self; ConfigRequirementsToml { allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value), + allowed_approvals_reviewers: allowed_approvals_reviewers.map(|sourced| sourced.value), allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value), allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value), feature_requirements: feature_requirements.map(|sourced| sourced.value), @@ -468,6 +688,7 @@ pub enum ResidencyRequirement { impl ConfigRequirementsToml { pub fn is_empty(&self) -> bool { self.allowed_approval_policies.is_none() + && self.allowed_approvals_reviewers.is_none() && self.allowed_sandbox_modes.is_none() && self.allowed_web_search_modes.is_none() && self @@ -495,6 +716,7 @@ impl TryFrom for ConfigRequirements { fn try_from(toml: ConfigRequirementsWithSources) -> Result { let ConfigRequirementsWithSources { allowed_approval_policies, + allowed_approvals_reviewers, allowed_sandbox_modes, allowed_web_search_modes, feature_requirements, @@ -536,6 +758,36 @@ impl TryFrom for ConfigRequirements { ), }; + let approvals_reviewer = match allowed_approvals_reviewers { + Some(Sourced { + value: reviewers, + source: requirement_source, + }) => { + let Some(initial_value) = reviewers.first().copied() else { + return Err(ConstraintError::empty_field("allowed_approvals_reviewers")); + }; + + let requirement_source_for_error = requirement_source.clone(); + let constrained = Constrained::new(initial_value, move |candidate| { + if reviewers.contains(candidate) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "approvals_reviewer", + candidate: format!("{candidate:?}"), + allowed: format!("{reviewers:?}"), + requirement_source: requirement_source_for_error.clone(), + }) + } + })?; + ConstrainedWithSource::new(constrained, Some(requirement_source)) + } + None => ConstrainedWithSource::new( + Constrained::allow_any_from_default(), + /*source*/ None, + ), + }; + // TODO(gt): `ConfigRequirementsToml` should let the author specify the // default `SandboxPolicy`? Should do this for `AskForApproval` too? // @@ -680,6 +932,7 @@ impl TryFrom for ConfigRequirements { }); Ok(ConfigRequirements { approval_policy, + approvals_reviewer, sandbox_policy, web_search_mode, feature_requirements, @@ -716,6 +969,7 @@ mod tests { fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources { let ConfigRequirementsToml { allowed_approval_policies, + allowed_approvals_reviewers, allowed_sandbox_modes, allowed_web_search_modes, feature_requirements, @@ -729,6 +983,8 @@ mod tests { ConfigRequirementsWithSources { allowed_approval_policies: allowed_approval_policies .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allowed_approvals_reviewers: allowed_approvals_reviewers + .map(|value| Sourced::new(value, RequirementSource::Unknown)), allowed_sandbox_modes: allowed_sandbox_modes .map(|value| Sourced::new(value, RequirementSource::Unknown)), allowed_web_search_modes: allowed_web_search_modes @@ -752,6 +1008,8 @@ mod tests { let source = RequirementSource::LegacyManagedConfigTomlFromMdm; let allowed_approval_policies = vec![AskForApproval::UnlessTrusted, AskForApproval::Never]; + let allowed_approvals_reviewers = + vec![ApprovalsReviewer::GuardianSubagent, ApprovalsReviewer::User]; let allowed_sandbox_modes = vec![ SandboxModeRequirement::WorkspaceWrite, SandboxModeRequirement::DangerFullAccess, @@ -772,6 +1030,7 @@ mod tests { // `ConfigRequirementsToml` forces this test to be updated. let other = ConfigRequirementsToml { allowed_approval_policies: Some(allowed_approval_policies.clone()), + allowed_approvals_reviewers: Some(allowed_approvals_reviewers.clone()), allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()), allowed_web_search_modes: Some(allowed_web_search_modes.clone()), feature_requirements: Some(feature_requirements.clone()), @@ -792,6 +1051,10 @@ mod tests { allowed_approval_policies, source.clone() )), + allowed_approvals_reviewers: Some(Sourced::new( + allowed_approvals_reviewers, + source.clone(), + )), allowed_sandbox_modes: Some(Sourced::new(allowed_sandbox_modes, source.clone(),)), allowed_web_search_modes: Some(Sourced::new( allowed_web_search_modes, @@ -836,6 +1099,7 @@ mod tests { vec![AskForApproval::OnRequest], source_location, )), + allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, feature_requirements: None, @@ -879,6 +1143,7 @@ mod tests { vec![AskForApproval::Never], existing_source, )), + allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, feature_requirements: None, @@ -959,6 +1224,18 @@ guardian_developer_instructions = """ Ok(()) } + #[test] + fn allowed_approvals_reviewers_is_not_empty() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" +allowed_approvals_reviewers = ["user"] +"#, + )?; + + assert!(!requirements.is_empty()); + Ok(()) + } + #[test] fn deserialize_apps_requirements() -> Result<()> { let toml_str = r#" @@ -1132,6 +1409,7 @@ guardian_developer_instructions = """ let source: ConfigRequirementsToml = from_str( r#" allowed_approval_policies = ["on-request"] + allowed_approvals_reviewers = ["guardian_subagent"] allowed_sandbox_modes = ["read-only"] "#, )?; @@ -1162,6 +1440,17 @@ guardian_developer_instructions = """ field_name: "sandbox_mode", candidate: "DangerFullAccess".into(), allowed: "[ReadOnly]".into(), + requirement_source: source_location.clone(), + }) + ); + assert_eq!( + requirements + .approvals_reviewer + .can_set(&ApprovalsReviewer::User), + Err(ConstraintError::InvalidValue { + field_name: "approvals_reviewer", + candidate: "User".into(), + allowed: "[GuardianSubagent]".into(), requirement_source: source_location, }) ); @@ -1201,6 +1490,7 @@ guardian_developer_instructions = """ let source: ConfigRequirementsToml = from_str( r#" allowed_approval_policies = ["on-request"] + allowed_approvals_reviewers = ["guardian_subagent"] allowed_sandbox_modes = ["read-only"] allowed_web_search_modes = ["cached"] enforce_residency = "us" @@ -1218,6 +1508,10 @@ guardian_developer_instructions = """ requirements.approval_policy.source, Some(source_location.clone()) ); + assert_eq!( + requirements.approvals_reviewer.source, + Some(source_location.clone()) + ); assert_eq!( requirements.sandbox_policy.source, Some(source_location.clone()) @@ -1293,6 +1587,54 @@ guardian_developer_instructions = """ Ok(()) } + #[test] + fn deserialize_allowed_approvals_reviewers() -> Result<()> { + let toml_str = r#" + allowed_approvals_reviewers = ["guardian_subagent", "user"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.approvals_reviewer.value(), + ApprovalsReviewer::GuardianSubagent, + "currently, there is no way to specify the default value for approvals reviewer in the toml, so it picks the first allowed value" + ); + assert!( + requirements + .approvals_reviewer + .can_set(&ApprovalsReviewer::GuardianSubagent) + .is_ok() + ); + assert!( + requirements + .approvals_reviewer + .can_set(&ApprovalsReviewer::User) + .is_ok() + ); + + Ok(()) + } + + #[test] + fn empty_allowed_approvals_reviewers_is_rejected() -> Result<()> { + let toml_str = r#" + allowed_approvals_reviewers = [] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let err = ConfigRequirements::try_from(with_unknown_source(config)) + .expect_err("empty approvals reviewer allow-list should be rejected"); + + assert_eq!( + err, + ConstraintError::EmptyField { + field_name: "allowed_approvals_reviewers".to_string(), + } + ); + + Ok(()) + } + #[test] fn deserialize_allowed_sandbox_modes() -> Result<()> { let toml_str = r#" @@ -1470,6 +1812,83 @@ guardian_developer_instructions = """ #[test] fn network_requirements_are_preserved_as_constraints_with_source() -> Result<()> { + let toml_str = r#" + [experimental_network] + enabled = true + allow_upstream_proxy = false + dangerously_allow_all_unix_sockets = true + managed_allowed_domains_only = true + danger_full_access_denylist_only = true + allow_local_binding = false + + [experimental_network.domains] + "api.example.com" = "allow" + "*.openai.com" = "allow" + "blocked.example.com" = "deny" + + [experimental_network.unix_sockets] + "/tmp/example.sock" = "allow" + "#; + + let source = RequirementSource::CloudRequirements; + let mut requirements_with_sources = ConfigRequirementsWithSources::default(); + requirements_with_sources.merge_unset_fields(source.clone(), from_str(toml_str)?); + + let requirements = ConfigRequirements::try_from(requirements_with_sources)?; + let sourced_network = requirements + .network + .expect("network requirements should be preserved as constraints"); + + assert_eq!(sourced_network.source, source); + assert_eq!(sourced_network.value.enabled, Some(true)); + assert_eq!(sourced_network.value.allow_upstream_proxy, Some(false)); + assert_eq!( + sourced_network.value.dangerously_allow_all_unix_sockets, + Some(true) + ); + assert_eq!( + sourced_network.value.domains.as_ref(), + Some(&NetworkDomainPermissionsToml { + entries: BTreeMap::from([ + ( + "*.openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "blocked.example.com".to_string(), + NetworkDomainPermissionToml::Deny, + ), + ]), + }) + ); + assert_eq!( + sourced_network.value.managed_allowed_domains_only, + Some(true) + ); + assert_eq!( + sourced_network.value.danger_full_access_denylist_only, + Some(true) + ); + assert_eq!( + sourced_network.value.unix_sockets.as_ref(), + Some(&NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([( + "/tmp/example.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + )]), + }) + ); + assert_eq!(sourced_network.value.allow_local_binding, Some(false)); + + Ok(()) + } + + #[test] + fn legacy_network_requirements_are_preserved_as_constraints_with_source() -> Result<()> { let toml_str = r#" [experimental_network] enabled = true @@ -1477,6 +1896,7 @@ guardian_developer_instructions = """ dangerously_allow_all_unix_sockets = true allowed_domains = ["api.example.com", "*.openai.com"] managed_allowed_domains_only = true + danger_full_access_denylist_only = true denied_domains = ["blocked.example.com"] allow_unix_sockets = ["/tmp/example.sock"] allow_local_binding = false @@ -1499,29 +1919,141 @@ guardian_developer_instructions = """ Some(true) ); assert_eq!( - sourced_network.value.allowed_domains.as_ref(), - Some(&vec![ - "api.example.com".to_string(), - "*.openai.com".to_string() - ]) + sourced_network.value.domains.as_ref(), + Some(&NetworkDomainPermissionsToml { + entries: BTreeMap::from([ + ( + "*.openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "blocked.example.com".to_string(), + NetworkDomainPermissionToml::Deny, + ), + ]), + }) ); assert_eq!( sourced_network.value.managed_allowed_domains_only, Some(true) ); assert_eq!( - sourced_network.value.denied_domains.as_ref(), - Some(&vec!["blocked.example.com".to_string()]) + sourced_network.value.danger_full_access_denylist_only, + Some(true) ); assert_eq!( - sourced_network.value.allow_unix_sockets.as_ref(), - Some(&vec!["/tmp/example.sock".to_string()]) + sourced_network.value.unix_sockets.as_ref(), + Some(&NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([( + "/tmp/example.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + )]), + }) ); assert_eq!(sourced_network.value.allow_local_binding, Some(false)); Ok(()) } + #[test] + fn mixed_legacy_and_canonical_network_requirements_are_rejected() { + let err = from_str::( + r#" + [experimental_network] + allowed_domains = ["api.example.com"] + + [experimental_network.domains] + "*.openai.com" = "allow" + "#, + ) + .expect_err("mixed network domain shapes should fail"); + + assert!( + err.to_string() + .contains("`experimental_network.domains` cannot be combined"), + "unexpected error: {err:#}" + ); + + let err = from_str::( + r#" + [experimental_network] + allow_unix_sockets = ["/tmp/example.sock"] + + [experimental_network.unix_sockets] + "/tmp/another.sock" = "allow" + "#, + ) + .expect_err("mixed network unix socket shapes should fail"); + + assert!( + err.to_string() + .contains("`experimental_network.unix_sockets` cannot be combined"), + "unexpected error: {err:#}" + ); + } + + #[test] + fn network_permission_containers_project_allowed_and_denied_entries() { + let domains = NetworkDomainPermissionsToml { + entries: BTreeMap::from([ + ( + "*.openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "blocked.example.com".to_string(), + NetworkDomainPermissionToml::Deny, + ), + ]), + }; + let unix_sockets = NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([ + ( + "/tmp/example.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ( + "/tmp/ignored.sock".to_string(), + NetworkUnixSocketPermissionToml::None, + ), + ]), + }; + + assert_eq!( + domains.allowed_domains(), + Some(vec![ + "*.openai.com".to_string(), + "api.example.com".to_string() + ]) + ); + assert_eq!( + domains.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); + assert_eq!( + NetworkDomainPermissionsToml { + entries: BTreeMap::from([( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + } + .denied_domains(), + None + ); + assert_eq!( + unix_sockets.allow_unix_sockets(), + vec!["/tmp/example.sock".to_string()] + ); + } + #[test] fn deserialize_mcp_server_requirements() -> Result<()> { let toml_str = r#" diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs new file mode 100644 index 0000000000..c7ecdfcac9 --- /dev/null +++ b/codex-rs/config/src/config_toml.rs @@ -0,0 +1,783 @@ +//! Schema-heavy configuration TOML types used by Codex. + +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::path::Path; + +use crate::permissions_toml::PermissionsToml; +use crate::profile_toml::ConfigProfile; +use crate::types::AnalyticsConfigToml; +use crate::types::ApprovalsReviewer; +use crate::types::AppsConfigToml; +use crate::types::AuthCredentialsStoreMode; +use crate::types::FeedbackConfigToml; +use crate::types::History; +use crate::types::McpServerConfig; +use crate::types::MemoriesToml; +use crate::types::Notice; +use crate::types::OAuthCredentialsStoreMode; +use crate::types::OtelConfigToml; +use crate::types::PluginConfig; +use crate::types::SandboxWorkspaceWrite; +use crate::types::ShellEnvironmentPolicyToml; +use crate::types::SkillsConfig; +use crate::types::ToolSuggestConfig; +use crate::types::Tui; +use crate::types::UriBasedFileOpener; +use crate::types::WindowsToml; +use codex_app_server_protocol::Tools; +use codex_app_server_protocol::UserSavedConfig; +use codex_features::FeaturesToml; +use codex_git_utils::resolve_root_git_project_for_trust; +use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID; +use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; +use codex_model_provider_info::ModelProviderInfo; +use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR; +use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; +use codex_model_provider_info::OPENAI_PROVIDER_ID; +use codex_protocol::config_types::ForcedLoginMethod; +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::TrustLevel; +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::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 schemars::JsonSchema; +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; + +const RESERVED_MODEL_PROVIDER_IDS: [&str; 3] = [ + OPENAI_PROVIDER_ID, + OLLAMA_OSS_PROVIDER_ID, + LMSTUDIO_OSS_PROVIDER_ID, +]; + +/// Base config deserialized from ~/.codex/config.toml. +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ConfigToml { + /// Optional override of model selection. + pub model: Option, + /// Review model override used by the `/review` feature. + pub review_model: Option, + + /// Provider to use from the model_providers map. + pub model_provider: Option, + + /// Size of the context window for the model, in tokens. + pub model_context_window: Option, + + /// Token usage threshold triggering auto-compaction of conversation history. + pub model_auto_compact_token_limit: Option, + + /// Default approval policy for executing commands. + pub approval_policy: Option, + + /// Configures who approval requests are routed to for review once they have + /// been escalated. This does not disable separate safety checks such as + /// ARC. + pub approvals_reviewer: Option, + + #[serde(default)] + pub shell_environment_policy: ShellEnvironmentPolicyToml, + + /// Whether the model may request a login shell for shell-based tools. + /// Default to `true` + /// + /// If `true`, the model may request a login shell (`login = true`), and + /// omitting `login` defaults to using a login shell. + /// If `false`, the model can never use a login shell: `login = true` + /// requests are rejected, and omitting `login` defaults to a non-login + /// shell. + pub allow_login_shell: Option, + + /// Sandbox mode to use. + pub sandbox_mode: Option, + + /// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`. + pub sandbox_workspace_write: Option, + + /// Default named permissions profile to apply from the `[permissions]` + /// table. + pub default_permissions: Option, + + /// Named permissions profiles. + #[serde(default)] + pub permissions: Option, + + /// Optional external command to spawn for end-user notifications. + #[serde(default)] + pub notify: Option>, + + /// System instructions. + pub instructions: Option, + + /// Developer instructions inserted as a `developer` role message. + #[serde(default)] + pub developer_instructions: Option, + + /// Whether to inject the `` developer block. + pub include_permissions_instructions: Option, + + /// Whether to inject the `` developer block. + pub include_apps_instructions: Option, + + /// Whether to inject the `` user block. + pub include_environment_context: Option, + + /// Optional path to a file containing model instructions that will override + /// the built-in instructions for the selected model. Users are STRONGLY + /// DISCOURAGED from using this field, as deviating from the instructions + /// sanctioned by Codex will likely degrade model performance. + pub model_instructions_file: Option, + + /// Compact prompt used for history compaction. + pub compact_prompt: Option, + + /// Optional commit attribution text for commit message co-author trailers. + /// + /// Set to an empty string to disable automatic commit attribution. + pub commit_attribution: Option, + + /// When set, restricts ChatGPT login to a specific workspace identifier. + #[serde(default)] + pub forced_chatgpt_workspace_id: Option, + + /// When set, restricts the login mechanism users may use. + #[serde(default)] + pub forced_login_method: Option, + + /// Preferred backend for storing CLI auth credentials. + /// file (default): Use a file in the Codex home directory. + /// keyring: Use an OS-specific keyring service. + /// auto: Use the keyring if available, otherwise use a file. + #[serde(default)] + pub cli_auth_credentials_store: Option, + + /// Definition for MCP servers that Codex can reach out to for tool calls. + #[serde(default)] + // Uses the raw MCP input shape (custom deserialization) rather than `McpServerConfig`. + #[schemars(schema_with = "crate::schema::mcp_servers_schema")] + pub mcp_servers: HashMap, + + /// Preferred backend for storing MCP OAuth credentials. + /// keyring: Use an OS-specific keyring service. + /// https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/oauth.rs#L2 + /// file: Use a file in the Codex home directory. + /// auto (default): Use the OS-specific keyring service if available, otherwise use a file. + #[serde(default)] + pub mcp_oauth_credentials_store: Option, + + /// Optional fixed port for the local HTTP callback server used during MCP OAuth login. + /// When unset, Codex will bind to an ephemeral port chosen by the OS. + pub mcp_oauth_callback_port: Option, + + /// Optional redirect URI to use during MCP OAuth login. + /// When set, this URI is used in the OAuth authorization request instead + /// of the local listener address. The local callback listener still binds + /// to 127.0.0.1 (using `mcp_oauth_callback_port` when provided). + pub mcp_oauth_callback_url: Option, + + /// User-defined provider entries that extend the built-in list. Built-in + /// IDs cannot be overridden. + #[serde(default, deserialize_with = "deserialize_model_providers")] + pub model_providers: HashMap, + + /// Maximum number of bytes to include from an AGENTS.md project doc file. + pub project_doc_max_bytes: Option, + + /// Ordered list of fallback filenames to look for when AGENTS.md is missing. + pub project_doc_fallback_filenames: Option>, + + /// Token budget applied when storing tool/function outputs in the context manager. + pub tool_output_token_limit: Option, + + /// Maximum poll window for background terminal output (`write_stdin`), in milliseconds. + /// Default: `300000` (5 minutes). + pub background_terminal_max_timeout: 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: Option>, + + /// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution. + pub zsh_path: Option, + + /// Profile to use from the `profiles` map. + pub profile: Option, + + /// Named profiles to facilitate switching between different configurations. + #[serde(default)] + pub profiles: HashMap, + + /// Settings that govern if and what will be written to `~/.codex/history.jsonl`. + #[serde(default)] + pub history: Option, + + /// Directory where Codex stores the SQLite state DB. + /// Defaults to `$CODEX_SQLITE_HOME` when set. Otherwise uses `$CODEX_HOME`. + pub sqlite_home: Option, + + /// Directory where Codex writes log files, for example `codex-tui.log`. + /// Defaults to `$CODEX_HOME/log`. + pub log_dir: Option, + + /// Optional URI-based file opener. If set, citations to files in the model + /// output will be hyperlinked using the specified URI scheme. + pub file_opener: Option, + + /// Collection of settings that are specific to the TUI. + pub tui: Option, + + /// When set to `true`, `AgentReasoning` events will be hidden from the + /// UI/output. Defaults to `false`. + pub hide_agent_reasoning: Option, + + /// When set to `true`, `AgentReasoningRawContentEvent` events will be shown in the UI/output. + /// Defaults to `false`. + pub show_raw_agent_reasoning: Option, + + pub model_reasoning_effort: Option, + pub plan_mode_reasoning_effort: Option, + pub model_reasoning_summary: Option, + /// Optional verbosity control for GPT-5 models (Responses API `text.verbosity`). + pub model_verbosity: Option, + + /// Override to force-enable reasoning summaries for the configured model. + pub model_supports_reasoning_summaries: Option, + + /// Optional path to a JSON model catalog (applied on startup only). + /// Per-thread `config` overrides are accepted but do not reapply this (no-ops). + pub model_catalog_json: Option, + + /// Optionally specify a personality for the model + pub personality: Option, + + /// Optional explicit service tier preference for new turns (`fast` or `flex`). + pub service_tier: Option, + + /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). + pub chatgpt_base_url: Option, + + /// Base URL override for the built-in `openai` model provider. + pub openai_base_url: Option, + + /// Machine-local realtime audio device preferences used by realtime voice. + #[serde(default)] + pub audio: Option, + + /// Experimental / do not use. Overrides only the realtime conversation + /// websocket transport base URL (the `Op::RealtimeConversation` + /// `/v1/realtime` + /// connection) without changing normal provider HTTP requests. + pub experimental_realtime_ws_base_url: Option, + /// Experimental / do not use. Selects the realtime websocket model/snapshot + /// used for the `Op::RealtimeConversation` connection. + pub experimental_realtime_ws_model: Option, + /// Experimental / do not use. Realtime websocket session selection. + /// `version` controls v1/v2 and `type` controls conversational/transcription. + #[serde(default)] + pub realtime: Option, + /// Experimental / do not use. Overrides only the realtime conversation + /// websocket transport instructions (the `Op::RealtimeConversation` + /// `/ws` session.update instructions) without changing normal prompts. + pub experimental_realtime_ws_backend_prompt: Option, + /// Experimental / do not use. Replaces the synthesized realtime startup + /// context appended to websocket session instructions. An empty string + /// disables startup context injection entirely. + pub experimental_realtime_ws_startup_context: Option, + /// Experimental / do not use. Replaces the built-in realtime start + /// instructions inserted into developer messages when realtime becomes + /// active. + pub experimental_realtime_start_instructions: Option, + pub projects: Option>, + + /// Controls the web search tool mode: disabled, cached, or live. + pub web_search: Option, + + /// Nested tools section for feature toggles + pub tools: Option, + + /// Additional discoverable tools that can be suggested for installation. + pub tool_suggest: Option, + + /// Agent-related settings (thread limits, etc.). + pub agents: Option, + + /// Memories subsystem settings. + pub memories: Option, + + /// User-level skill config entries keyed by SKILL.md path. + pub skills: Option, + + /// User-level plugin config entries keyed by plugin name. + #[serde(default)] + pub plugins: HashMap, + + /// Centralized feature flags (new). Prefer this over individual toggles. + #[serde(default)] + // Injects known feature keys into the schema and forbids unknown keys. + #[schemars(schema_with = "crate::schema::features_schema")] + pub features: Option, + + /// Suppress warnings about unstable (under development) features. + pub suppress_unstable_features_warning: Option, + + /// Settings for ghost snapshots (used for undo). + #[serde(default)] + pub ghost_snapshot: Option, + + /// Markers used to detect the project root when searching parent + /// directories for `.codex` folders. Defaults to [".git"] when unset. + #[serde(default)] + pub project_root_markers: Option>, + + /// When `true`, checks for Codex updates on startup and surfaces update prompts. + /// Set to `false` only if your Codex updates are centrally managed. + /// Defaults to `true`. + pub check_for_update_on_startup: Option, + + /// When true, disables burst-paste detection for typed input entirely. + /// All characters are inserted as they are received, and no buffering + /// or placeholder replacement will occur for fast keypress bursts. + pub disable_paste_burst: Option, + + /// When `false`, disables analytics across Codex product surfaces in this machine. + /// Defaults to `true`. + pub analytics: Option, + + /// When `false`, disables feedback collection across Codex product surfaces. + /// Defaults to `true`. + pub feedback: Option, + + /// Settings for app-specific controls. + #[serde(default)] + pub apps: Option, + + /// OTEL configuration. + pub otel: Option, + + /// Windows-specific configuration. + #[serde(default)] + pub windows: Option, + + /// Tracks whether the Windows onboarding screen has been acknowledged. + pub windows_wsl_setup_acknowledged: Option, + + /// Collection of in-product notices (different from notifications) + /// See [`crate::types::Notice`] for more details + pub notice: Option, + + /// Legacy, now use features + /// Deprecated: ignored. Use `model_instructions_file`. + #[schemars(skip)] + pub experimental_instructions_file: Option, + pub experimental_compact_prompt_file: Option, + pub experimental_use_unified_exec_tool: Option, + pub experimental_use_freeform_apply_patch: Option, + /// Preferred OSS provider for local models, e.g. "lmstudio" or "ollama". + pub oss_provider: Option, +} + +impl From for UserSavedConfig { + fn from(config_toml: ConfigToml) -> Self { + let profiles = config_toml + .profiles + .into_iter() + .map(|(k, v)| (k, v.into())) + .collect(); + + Self { + approval_policy: config_toml.approval_policy, + sandbox_mode: config_toml.sandbox_mode, + sandbox_settings: config_toml.sandbox_workspace_write.map(From::from), + forced_chatgpt_workspace_id: config_toml.forced_chatgpt_workspace_id, + forced_login_method: config_toml.forced_login_method, + model: config_toml.model, + model_reasoning_effort: config_toml.model_reasoning_effort, + model_reasoning_summary: config_toml.model_reasoning_summary, + model_verbosity: config_toml.model_verbosity, + tools: config_toml.tools.map(From::from), + profile: config_toml.profile, + profiles, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ProjectConfig { + pub trust_level: Option, +} + +impl ProjectConfig { + pub fn is_trusted(&self) -> bool { + matches!(self.trust_level, Some(TrustLevel::Trusted)) + } + + pub fn is_untrusted(&self) -> bool { + matches!(self.trust_level, Some(TrustLevel::Untrusted)) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RealtimeAudioConfig { + pub microphone: Option, + pub speaker: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RealtimeWsMode { + #[default] + Conversational, + Transcription, +} + +pub use codex_protocol::protocol::RealtimeConversationVersion as RealtimeWsVersion; + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct RealtimeConfig { + pub version: RealtimeWsVersion, + #[serde(rename = "type")] + pub session_type: RealtimeWsMode, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct RealtimeToml { + pub version: Option, + #[serde(rename = "type")] + pub session_type: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct RealtimeAudioToml { + pub microphone: Option, + pub speaker: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ToolsToml { + #[serde( + default, + deserialize_with = "deserialize_optional_web_search_tool_config" + )] + pub web_search: Option, + + /// Enable the `view_image` tool that lets the agent attach local images. + #[serde(default)] + pub view_image: Option, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum WebSearchToolConfigInput { + Enabled(bool), + Config(WebSearchToolConfig), +} + +fn deserialize_optional_web_search_tool_config<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + + Ok(match value { + None => None, + Some(WebSearchToolConfigInput::Enabled(enabled)) => { + let _ = enabled; + None + } + Some(WebSearchToolConfigInput::Config(config)) => Some(config), + }) +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AgentsToml { + /// Maximum number of agent threads that can be open concurrently. + /// When unset, no limit is enforced. + #[schemars(range(min = 1))] + pub max_threads: Option, + /// Maximum nesting depth allowed for spawned agent threads. + /// Root sessions start at depth 0. + #[schemars(range(min = 1))] + pub max_depth: Option, + /// Default maximum runtime in seconds for agent job workers. + #[schemars(range(min = 1))] + pub job_max_runtime_seconds: Option, + + /// User-defined role declarations keyed by role name. + /// + /// Example: + /// ```toml + /// [agents.researcher] + /// description = "Research-focused role." + /// config_file = "./agents/researcher.toml" + /// nickname_candidates = ["Herodotus", "Ibn Battuta"] + /// ``` + #[serde(default, flatten)] + pub roles: BTreeMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AgentRoleToml { + /// Human-facing role documentation used in spawn tool guidance. + /// Required unless supplied by the referenced agent role file. + pub description: Option, + + /// Path to a role-specific config layer. + /// Relative paths are resolved relative to the `config.toml` that defines them. + pub config_file: Option, + + /// Candidate nicknames for agents spawned with this role. + pub nickname_candidates: Option>, +} + +impl From for Tools { + fn from(tools_toml: ToolsToml) -> Self { + Self { + web_search: tools_toml.web_search.is_some().then_some(true), + view_image: tools_toml.view_image, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct GhostSnapshotToml { + /// Exclude untracked files larger than this many bytes from ghost snapshots. + #[serde(alias = "ignore_untracked_files_over_bytes")] + pub ignore_large_untracked_files: Option, + /// Ignore untracked directories that contain this many files or more. + /// (Still emits a warning unless warnings are disabled.) + #[serde(alias = "large_untracked_dir_warning_threshold")] + pub ignore_large_untracked_dirs: Option, + /// Disable all ghost snapshot warning events. + pub disable_warnings: Option, +} + +impl ConfigToml { + /// Derive the effective sandbox policy from the configuration. + pub async fn derive_sandbox_policy( + &self, + sandbox_mode_override: Option, + profile_sandbox_mode: Option, + windows_sandbox_level: WindowsSandboxLevel, + resolved_cwd: &Path, + sandbox_policy_constraint: Option<&crate::Constrained>, + ) -> SandboxPolicy { + let sandbox_mode_was_explicit = sandbox_mode_override.is_some() + || profile_sandbox_mode.is_some() + || self.sandbox_mode.is_some(); + // If no sandbox_mode is set but this directory has a trust decision, + // default to workspace-write except on unsandboxed Windows where we + // default to read-only. + let trust_default_sandbox_mode = if sandbox_mode_was_explicit { + None + } else { + self.get_active_project(resolved_cwd) + .await + .and_then(|project| { + if project.is_trusted() || project.is_untrusted() { + if cfg!(target_os = "windows") + && windows_sandbox_level == WindowsSandboxLevel::Disabled + { + Some(SandboxMode::ReadOnly) + } else { + Some(SandboxMode::WorkspaceWrite) + } + } else { + None + } + }) + }; + let resolved_sandbox_mode = sandbox_mode_override + .or(profile_sandbox_mode) + .or(self.sandbox_mode) + .or(trust_default_sandbox_mode) + .unwrap_or_default(); + let mut sandbox_policy = match resolved_sandbox_mode { + SandboxMode::ReadOnly => SandboxPolicy::new_read_only_policy(), + SandboxMode::WorkspaceWrite => match self.sandbox_workspace_write.as_ref() { + Some(SandboxWorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + }) => SandboxPolicy::WorkspaceWrite { + writable_roots: writable_roots.clone(), + read_only_access: ReadOnlyAccess::FullAccess, + network_access: *network_access, + exclude_tmpdir_env_var: *exclude_tmpdir_env_var, + exclude_slash_tmp: *exclude_slash_tmp, + }, + None => SandboxPolicy::new_workspace_write_policy(), + }, + SandboxMode::DangerFullAccess => SandboxPolicy::DangerFullAccess, + }; + let downgrade_workspace_write_if_unsupported = |policy: &mut SandboxPolicy| { + if cfg!(target_os = "windows") + // If the experimental Windows sandbox is enabled, do not force a downgrade. + && windows_sandbox_level == WindowsSandboxLevel::Disabled + && matches!(&*policy, SandboxPolicy::WorkspaceWrite { .. }) + { + *policy = SandboxPolicy::new_read_only_policy(); + } + }; + if matches!(resolved_sandbox_mode, SandboxMode::WorkspaceWrite) { + downgrade_workspace_write_if_unsupported(&mut sandbox_policy); + } + if !sandbox_mode_was_explicit + && let Some(constraint) = sandbox_policy_constraint + && let Err(err) = constraint.can_set(&sandbox_policy) + { + tracing::warn!( + error = %err, + "default sandbox policy is disallowed by requirements; falling back to required default" + ); + sandbox_policy = constraint.get().clone(); + downgrade_workspace_write_if_unsupported(&mut sandbox_policy); + } + sandbox_policy + } + + /// Resolves the cwd to an existing project, or returns None if ConfigToml + /// does not contain a project corresponding to cwd or a git repo for cwd + pub async fn get_active_project(&self, resolved_cwd: &Path) -> Option { + let projects = self.projects.clone().unwrap_or_default(); + + let resolved_cwd_key = project_trust_key(resolved_cwd); + let resolved_cwd_raw_key = resolved_cwd.to_string_lossy().to_string(); + if let Some(project_config) = projects + .get(&resolved_cwd_key) + .or_else(|| projects.get(&resolved_cwd_raw_key)) + { + return Some(project_config.clone()); + } + + // If cwd lives inside a git repo/worktree, check whether the root git project + // (the primary repository working directory) is trusted. This lets + // worktrees inherit trust from the main project. + if let Some(repo_root) = resolve_root_git_project_for_trust(resolved_cwd).await { + let repo_root_key = project_trust_key(repo_root.as_path()); + let repo_root_raw_key = repo_root.to_string_lossy().to_string(); + if let Some(project_config_for_root) = projects + .get(&repo_root_key) + .or_else(|| projects.get(&repo_root_raw_key)) + { + return Some(project_config_for_root.clone()); + } + } + + None + } + + pub fn get_config_profile( + &self, + override_profile: Option, + ) -> Result { + let profile = override_profile.or_else(|| self.profile.clone()); + + match profile { + Some(key) => { + if let Some(profile) = self.profiles.get(key.as_str()) { + return Ok(profile.clone()); + } + + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("config profile `{key}` not found"), + )) + } + None => Ok(ConfigProfile::default()), + } + } +} + +/// Canonicalize the path and convert it to a string to be used as a key in the +/// projects trust map. On Windows, strips UNC, when possible, to try to ensure +/// that different paths that point to the same location have the same key. +fn project_trust_key(project_path: &Path) -> String { + dunce::canonicalize(project_path) + .unwrap_or_else(|_| project_path.to_path_buf()) + .to_string_lossy() + .to_string() +} + +pub fn validate_reserved_model_provider_ids( + model_providers: &HashMap, +) -> Result<(), String> { + let mut conflicts = model_providers + .keys() + .filter(|key| RESERVED_MODEL_PROVIDER_IDS.contains(&key.as_str())) + .map(|key| format!("`{key}`")) + .collect::>(); + conflicts.sort_unstable(); + if conflicts.is_empty() { + Ok(()) + } else { + Err(format!( + "model_providers contains reserved built-in provider IDs: {}. \ +Built-in providers cannot be overridden. Rename your custom provider (for example, `openai-custom`).", + conflicts.join(", ") + )) + } +} + +pub fn validate_model_providers( + model_providers: &HashMap, +) -> Result<(), String> { + validate_reserved_model_provider_ids(model_providers)?; + for (key, provider) in model_providers { + provider + .validate() + .map_err(|message| format!("model_providers.{key}: {message}"))?; + } + Ok(()) +} + +fn deserialize_model_providers<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let model_providers = HashMap::::deserialize(deserializer)?; + validate_model_providers(&model_providers).map_err(serde::de::Error::custom)?; + Ok(model_providers) +} + +pub fn validate_oss_provider(provider: &str) -> std::io::Result<()> { + match provider { + LMSTUDIO_OSS_PROVIDER_ID | OLLAMA_OSS_PROVIDER_ID => Ok(()), + LEGACY_OLLAMA_CHAT_PROVIDER_ID => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + OLLAMA_CHAT_PROVIDER_REMOVED_ERROR, + )), + _ => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "Invalid OSS provider '{provider}'. Must be one of: {LMSTUDIO_OSS_PROVIDER_ID}, {OLLAMA_OSS_PROVIDER_ID}" + ), + )), + } +} diff --git a/codex-rs/config/src/constraint.rs b/codex-rs/config/src/constraint.rs index d6c8e09f5f..13cb867ce1 100644 --- a/codex-rs/config/src/constraint.rs +++ b/codex-rs/config/src/constraint.rs @@ -188,8 +188,10 @@ mod tests { #[test] fn constrained_allow_any_accepts_any_value() { - let mut constrained = Constrained::allow_any(5); - constrained.set(-10).expect("allow any accepts all values"); + let mut constrained = Constrained::allow_any(/*initial_value*/ 5); + constrained + .set(/*value*/ -10) + .expect("allow any accepts all values"); assert_eq!(constrained.value(), -10); } @@ -201,13 +203,13 @@ mod tests { #[test] fn constrained_allow_only_rejects_different_values() { - let mut constrained = Constrained::allow_only(5); + let mut constrained = Constrained::allow_only(/*only_value*/ 5); constrained - .set(5) + .set(/*value*/ 5) .expect("allowed value should be accepted"); let err = constrained - .set(6) + .set(/*value*/ 6) .expect_err("different value should be rejected"); assert_eq!(err, invalid_value("6", "[5]")); assert_eq!(constrained.value(), 5); @@ -215,18 +217,19 @@ mod tests { #[test] fn constrained_normalizer_applies_on_init_and_set() -> anyhow::Result<()> { - let mut constrained = Constrained::normalized(-1, |value| value.max(0))?; + let mut constrained = + Constrained::normalized(/*initial_value*/ -1, |value| value.max(0))?; assert_eq!(constrained.value(), 0); - constrained.set(-5)?; + constrained.set(/*value*/ -5)?; assert_eq!(constrained.value(), 0); - constrained.set(10)?; + constrained.set(/*value*/ 10)?; assert_eq!(constrained.value(), 10); Ok(()) } #[test] fn constrained_new_rejects_invalid_initial_value() { - let result = Constrained::new(0, |value| { + let result = Constrained::new(/*initial_value*/ 0, |value| { if *value > 0 { Ok(()) } else { @@ -239,7 +242,7 @@ mod tests { #[test] fn constrained_set_rejects_invalid_value_and_leaves_previous() { - let mut constrained = Constrained::new(1, |value| { + let mut constrained = Constrained::new(/*initial_value*/ 1, |value| { if *value > 0 { Ok(()) } else { @@ -249,7 +252,7 @@ mod tests { .expect("initial value should be accepted"); let err = constrained - .set(-5) + .set(/*value*/ -5) .expect_err("negative values should be rejected"); assert_eq!(err, invalid_value("-5", "positive values")); assert_eq!(constrained.value(), 1); @@ -257,7 +260,7 @@ mod tests { #[test] fn constrained_can_set_allows_probe_without_setting() { - let constrained = Constrained::new(1, |value| { + let constrained = Constrained::new(/*initial_value*/ 1, |value| { if *value > 0 { Ok(()) } else { diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 7bd572fea3..5b8e5e6878 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -1,14 +1,21 @@ mod cloud_requirements; mod config_requirements; +pub mod config_toml; mod constraint; mod diagnostics; mod fingerprint; +mod mcp_edit; +mod mcp_types; mod merge; mod overrides; +pub mod permissions_toml; +pub mod profile_toml; mod project_root_markers; mod requirements_exec_policy; +pub mod schema; mod skills_config; mod state; +pub mod types; pub const CONFIG_TOML_FILE: &str = "config.toml"; @@ -25,7 +32,11 @@ pub use config_requirements::FeatureRequirementsToml; pub use config_requirements::McpServerIdentity; pub use config_requirements::McpServerRequirement; pub use config_requirements::NetworkConstraints; +pub use config_requirements::NetworkDomainPermissionToml; +pub use config_requirements::NetworkDomainPermissionsToml; pub use config_requirements::NetworkRequirementsToml; +pub use config_requirements::NetworkUnixSocketPermissionToml; +pub use config_requirements::NetworkUnixSocketPermissionsToml; pub use config_requirements::RequirementSource; pub use config_requirements::ResidencyRequirement; pub use config_requirements::SandboxModeRequirement; @@ -46,6 +57,14 @@ pub use diagnostics::format_config_error; pub use diagnostics::format_config_error_with_source; pub use diagnostics::io_error_from_config_error; pub use fingerprint::version_for_toml; +pub use mcp_edit::ConfigEditsBuilder; +pub use mcp_edit::load_global_mcp_servers; +pub use mcp_types::AppToolApproval; +pub use mcp_types::McpServerConfig; +pub use mcp_types::McpServerDisabledReason; +pub use mcp_types::McpServerToolConfig; +pub use mcp_types::McpServerTransportConfig; +pub use mcp_types::RawMcpServerConfig; pub use merge::merge_toml_values; pub use overrides::build_cli_overrides_layer; pub use project_root_markers::default_project_root_markers; diff --git a/codex-rs/config/src/mcp_edit.rs b/codex-rs/config/src/mcp_edit.rs new file mode 100644 index 0000000000..f528751f26 --- /dev/null +++ b/codex-rs/config/src/mcp_edit.rs @@ -0,0 +1,253 @@ +use std::collections::BTreeMap; +use std::fs; +use std::io::ErrorKind; +use std::path::Path; +use std::path::PathBuf; + +use tokio::task; +use toml::Value as TomlValue; +use toml_edit::DocumentMut; +use toml_edit::Item as TomlItem; +use toml_edit::Table as TomlTable; +use toml_edit::value; + +use crate::AppToolApproval; +use crate::CONFIG_TOML_FILE; +use crate::McpServerConfig; +use crate::McpServerTransportConfig; + +pub async fn load_global_mcp_servers( + codex_home: &Path, +) -> std::io::Result> { + let config_path = codex_home.join(CONFIG_TOML_FILE); + let raw = match tokio::fs::read_to_string(&config_path).await { + Ok(raw) => raw, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(BTreeMap::new()), + Err(err) => return Err(err), + }; + let parsed = toml::from_str::(&raw) + .map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err))?; + let Some(servers_value) = parsed.get("mcp_servers") else { + return Ok(BTreeMap::new()); + }; + + ensure_no_inline_bearer_tokens(servers_value)?; + + servers_value + .clone() + .try_into() + .map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err)) +} + +fn ensure_no_inline_bearer_tokens(value: &TomlValue) -> std::io::Result<()> { + let Some(servers_table) = value.as_table() else { + return Ok(()); + }; + + for (server_name, server_value) in servers_table { + if let Some(server_table) = server_value.as_table() + && server_table.contains_key("bearer_token") + { + let message = format!( + "mcp_servers.{server_name} uses unsupported `bearer_token`; set `bearer_token_env_var`." + ); + return Err(std::io::Error::new(ErrorKind::InvalidData, message)); + } + } + + Ok(()) +} + +pub struct ConfigEditsBuilder { + codex_home: PathBuf, + mcp_servers: Option>, +} + +impl ConfigEditsBuilder { + pub fn new(codex_home: &Path) -> Self { + Self { + codex_home: codex_home.to_path_buf(), + mcp_servers: None, + } + } + + pub fn replace_mcp_servers(mut self, servers: &BTreeMap) -> Self { + self.mcp_servers = Some(servers.clone()); + self + } + + pub async fn apply(self) -> std::io::Result<()> { + task::spawn_blocking(move || self.apply_blocking()) + .await + .map_err(|err| { + std::io::Error::other(format!("config persistence task panicked: {err}")) + })? + } + + fn apply_blocking(self) -> std::io::Result<()> { + let config_path = self.codex_home.join(CONFIG_TOML_FILE); + let mut doc = read_or_create_document(&config_path)?; + if let Some(servers) = self.mcp_servers.as_ref() { + replace_mcp_servers(&mut doc, servers); + } + fs::create_dir_all(&self.codex_home)?; + fs::write(config_path, doc.to_string()) + } +} + +fn read_or_create_document(config_path: &Path) -> std::io::Result { + match fs::read_to_string(config_path) { + Ok(raw) => raw + .parse::() + .map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err)), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(DocumentMut::new()), + Err(err) => Err(err), + } +} + +fn replace_mcp_servers(doc: &mut DocumentMut, servers: &BTreeMap) { + let root = doc.as_table_mut(); + if servers.is_empty() { + root.remove("mcp_servers"); + return; + } + + let mut table = TomlTable::new(); + table.set_implicit(true); + for (name, config) in servers { + table.insert(name, serialize_mcp_server(config)); + } + root.insert("mcp_servers", TomlItem::Table(table)); +} + +fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem { + let mut entry = TomlTable::new(); + entry.set_implicit(false); + + match &config.transport { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { + entry["command"] = value(command.clone()); + if !args.is_empty() { + entry["args"] = array_from_strings(args); + } + if let Some(env) = env + && !env.is_empty() + { + entry["env"] = table_from_pairs(env.iter()); + } + if !env_vars.is_empty() { + entry["env_vars"] = array_from_strings(env_vars); + } + if let Some(cwd) = cwd { + entry["cwd"] = value(cwd.to_string_lossy().to_string()); + } + } + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers, + env_http_headers, + } => { + entry["url"] = value(url.clone()); + if let Some(env_var) = bearer_token_env_var { + entry["bearer_token_env_var"] = value(env_var.clone()); + } + if let Some(headers) = http_headers + && !headers.is_empty() + { + entry["http_headers"] = table_from_pairs(headers.iter()); + } + if let Some(headers) = env_http_headers + && !headers.is_empty() + { + entry["env_http_headers"] = table_from_pairs(headers.iter()); + } + } + } + + if !config.enabled { + entry["enabled"] = value(false); + } + if config.required { + entry["required"] = value(true); + } + if let Some(timeout) = config.startup_timeout_sec { + entry["startup_timeout_sec"] = value(timeout.as_secs_f64()); + } + if let Some(timeout) = config.tool_timeout_sec { + entry["tool_timeout_sec"] = value(timeout.as_secs_f64()); + } + if let Some(enabled_tools) = &config.enabled_tools + && !enabled_tools.is_empty() + { + entry["enabled_tools"] = array_from_strings(enabled_tools); + } + if let Some(disabled_tools) = &config.disabled_tools + && !disabled_tools.is_empty() + { + entry["disabled_tools"] = array_from_strings(disabled_tools); + } + if let Some(scopes) = &config.scopes + && !scopes.is_empty() + { + entry["scopes"] = array_from_strings(scopes); + } + if let Some(resource) = &config.oauth_resource + && !resource.is_empty() + { + entry["oauth_resource"] = value(resource.clone()); + } + if !config.tools.is_empty() { + let mut tools = TomlTable::new(); + tools.set_implicit(false); + let mut tool_entries: Vec<_> = config.tools.iter().collect(); + tool_entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + for (name, tool_config) in tool_entries { + let mut tool_entry = TomlTable::new(); + tool_entry.set_implicit(false); + if let Some(approval_mode) = tool_config.approval_mode { + tool_entry["approval_mode"] = value(match approval_mode { + AppToolApproval::Auto => "auto", + AppToolApproval::Prompt => "prompt", + AppToolApproval::Approve => "approve", + }); + } + tools.insert(name, TomlItem::Table(tool_entry)); + } + entry.insert("tools", TomlItem::Table(tools)); + } + + TomlItem::Table(entry) +} + +fn array_from_strings(values: &[String]) -> TomlItem { + let mut array = toml_edit::Array::new(); + for value in values { + array.push(value.clone()); + } + TomlItem::Value(array.into()) +} + +fn table_from_pairs<'a, I>(pairs: I) -> TomlItem +where + I: IntoIterator, +{ + let mut entries: Vec<_> = pairs.into_iter().collect(); + entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + let mut table = TomlTable::new(); + table.set_implicit(false); + for (key, value_str) in entries { + table.insert(key, value(value_str.clone())); + } + TomlItem::Table(table) +} + +#[cfg(test)] +#[path = "mcp_edit_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/mcp_edit_tests.rs b/codex-rs/config/src/mcp_edit_tests.rs new file mode 100644 index 0000000000..3a8eddee01 --- /dev/null +++ b/codex-rs/config/src/mcp_edit_tests.rs @@ -0,0 +1,79 @@ +use super::*; +use crate::McpServerToolConfig; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +#[tokio::test] +async fn replace_mcp_servers_serializes_per_tool_approval_overrides() -> anyhow::Result<()> { + let unique_suffix = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let codex_home = std::env::temp_dir().join(format!( + "codex-config-mcp-edit-test-{}-{unique_suffix}", + std::process::id() + )); + let servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "docs-server".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + tools: HashMap::from([ + ( + "search".to_string(), + McpServerToolConfig { + approval_mode: Some(AppToolApproval::Approve), + }, + ), + ( + "read".to_string(), + McpServerToolConfig { + approval_mode: Some(AppToolApproval::Prompt), + }, + ), + ]), + }, + )]); + + ConfigEditsBuilder::new(&codex_home) + .replace_mcp_servers(&servers) + .apply() + .await?; + + let config_path = codex_home.join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert_eq!( + serialized, + r#"[mcp_servers.docs] +command = "docs-server" + +[mcp_servers.docs.tools] + +[mcp_servers.docs.tools.read] +approval_mode = "prompt" + +[mcp_servers.docs.tools.search] +approval_mode = "approve" +"# + ); + + let loaded = load_global_mcp_servers(&codex_home).await?; + assert_eq!(loaded, servers); + + std::fs::remove_dir_all(&codex_home)?; + + Ok(()) +} diff --git a/codex-rs/config/src/mcp_types.rs b/codex-rs/config/src/mcp_types.rs new file mode 100644 index 0000000000..52cf71b49b --- /dev/null +++ b/codex-rs/config/src/mcp_types.rs @@ -0,0 +1,331 @@ +//! MCP server configuration types. + +use std::collections::HashMap; +use std::fmt; +use std::path::PathBuf; +use std::time::Duration; + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; +use serde::de::Error as SerdeError; + +use crate::RequirementSource; + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum AppToolApproval { + #[default] + Auto, + Prompt, + Approve, +} + +/// Human-readable reason a configured MCP server was disabled after requirements +/// were applied. +/// +/// `Display` is intentionally implemented for CLI/TUI status output; avoid +/// relying on `Debug` because enum variant syntax is not part of the user-facing +/// message contract. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum McpServerDisabledReason { + /// The server is disabled, but there is no more specific user-facing reason. + Unknown, + /// The server was disabled by config requirements from the given source. + Requirements { source: RequirementSource }, +} + +impl fmt::Display for McpServerDisabledReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + McpServerDisabledReason::Unknown => write!(f, "unknown"), + McpServerDisabledReason::Requirements { source } => { + write!(f, "requirements ({source})") + } + } + } +} + +/// Per-tool approval settings for a single MCP server tool. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct McpServerToolConfig { + /// Approval mode for this tool. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_mode: Option, +} + +#[derive(Serialize, Debug, Clone, PartialEq)] +pub struct McpServerConfig { + #[serde(flatten)] + pub transport: McpServerTransportConfig, + + /// When `false`, Codex skips initializing this MCP server. + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// When `true`, `codex exec` exits with an error if this MCP server fails to initialize. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub required: bool, + + /// Reason this server was disabled after applying requirements. + #[serde(skip)] + pub disabled_reason: Option, + + /// Startup timeout in seconds for initializing MCP server & initially listing tools. + #[serde( + default, + with = "option_duration_secs", + skip_serializing_if = "Option::is_none" + )] + pub startup_timeout_sec: Option, + + /// Default timeout for MCP tool calls initiated via this server. + #[serde(default, with = "option_duration_secs")] + pub tool_timeout_sec: Option, + + /// Explicit allow-list of tools exposed from this server. When set, only these tools will be registered. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled_tools: Option>, + + /// Explicit deny-list of tools. These tools will be removed after applying `enabled_tools`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disabled_tools: Option>, + + /// Optional OAuth scopes to request during MCP login. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scopes: Option>, + + /// Optional OAuth resource parameter to include during MCP login (RFC 8707). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub oauth_resource: Option, + + /// Per-tool approval settings keyed by tool name. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub tools: HashMap, +} + +/// Raw MCP config shape used for deserialization and JSON Schema generation. +/// +/// Keep `TryFrom for McpServerConfig` exhaustively +/// destructuring this struct so new TOML fields cannot be added here without +/// updating the validation/mapping logic that produces [`McpServerConfig`]. +#[derive(Deserialize, Clone, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct RawMcpServerConfig { + // stdio + pub command: Option, + #[serde(default)] + pub args: Option>, + #[serde(default)] + pub env: Option>, + #[serde(default)] + pub env_vars: Option>, + #[serde(default)] + pub cwd: Option, + pub http_headers: Option>, + #[serde(default)] + pub env_http_headers: Option>, + + // streamable_http + pub url: Option, + pub bearer_token: Option, + pub bearer_token_env_var: Option, + + // shared + #[serde(default)] + pub startup_timeout_sec: Option, + #[serde(default)] + pub startup_timeout_ms: Option, + #[serde(default, with = "option_duration_secs")] + #[schemars(with = "Option")] + pub tool_timeout_sec: Option, + #[serde(default)] + pub enabled: Option, + #[serde(default)] + pub required: Option, + #[serde(default)] + pub enabled_tools: Option>, + #[serde(default)] + pub disabled_tools: Option>, + #[serde(default)] + pub scopes: Option>, + #[serde(default)] + pub oauth_resource: Option, + /// Legacy display-name field accepted for backward compatibility. + #[serde(default, rename = "name")] + pub _name: Option, + #[serde(default)] + pub tools: Option>, +} + +impl TryFrom for McpServerConfig { + type Error = String; + + fn try_from(raw: RawMcpServerConfig) -> Result { + let RawMcpServerConfig { + command, + args, + env, + env_vars, + cwd, + http_headers, + env_http_headers, + url, + bearer_token, + bearer_token_env_var, + startup_timeout_sec, + startup_timeout_ms, + tool_timeout_sec, + enabled, + required, + enabled_tools, + disabled_tools, + scopes, + oauth_resource, + _name: _, + tools, + } = raw; + + let startup_timeout_sec = match (startup_timeout_sec, startup_timeout_ms) { + (Some(sec), _) => { + Some(Duration::try_from_secs_f64(sec).map_err(|err| err.to_string())?) + } + (None, Some(ms)) => Some(Duration::from_millis(ms)), + (None, None) => None, + }; + + fn throw_if_set(transport: &str, field: &str, value: Option<&T>) -> Result<(), String> { + if value.is_none() { + return Ok(()); + } + Err(format!("{field} is not supported for {transport}")) + } + + let transport = if let Some(command) = command { + throw_if_set("stdio", "url", url.as_ref())?; + throw_if_set( + "stdio", + "bearer_token_env_var", + bearer_token_env_var.as_ref(), + )?; + throw_if_set("stdio", "bearer_token", bearer_token.as_ref())?; + throw_if_set("stdio", "http_headers", http_headers.as_ref())?; + throw_if_set("stdio", "env_http_headers", env_http_headers.as_ref())?; + throw_if_set("stdio", "oauth_resource", oauth_resource.as_ref())?; + McpServerTransportConfig::Stdio { + command, + args: args.unwrap_or_default(), + env, + env_vars: env_vars.unwrap_or_default(), + cwd, + } + } else if let Some(url) = url { + throw_if_set("streamable_http", "args", args.as_ref())?; + throw_if_set("streamable_http", "env", env.as_ref())?; + throw_if_set("streamable_http", "env_vars", env_vars.as_ref())?; + throw_if_set("streamable_http", "cwd", cwd.as_ref())?; + throw_if_set("streamable_http", "bearer_token", bearer_token.as_ref())?; + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers, + env_http_headers, + } + } else { + return Err("invalid transport".to_string()); + }; + + Ok(Self { + transport, + startup_timeout_sec, + tool_timeout_sec, + enabled: enabled.unwrap_or_else(default_enabled), + required: required.unwrap_or_default(), + disabled_reason: None, + enabled_tools, + disabled_tools, + scopes, + oauth_resource, + tools: tools.unwrap_or_default(), + }) + } +} + +impl<'de> Deserialize<'de> for McpServerConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + RawMcpServerConfig::deserialize(deserializer)? + .try_into() + .map_err(SerdeError::custom) + } +} + +const fn default_enabled() -> bool { + true +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] +#[serde(untagged, deny_unknown_fields, rename_all = "snake_case")] +pub enum McpServerTransportConfig { + /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio + Stdio { + command: String, + #[serde(default)] + args: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + env: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + env_vars: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + cwd: Option, + }, + /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http + StreamableHttp { + url: String, + /// Name of the environment variable to read for an HTTP bearer token. + /// When set, requests will include the token via `Authorization: Bearer `. + /// The actual secret value must be provided via the environment. + #[serde(default, skip_serializing_if = "Option::is_none")] + bearer_token_env_var: Option, + /// Additional HTTP headers to include in requests to this server. + #[serde(default, skip_serializing_if = "Option::is_none")] + http_headers: Option>, + /// HTTP headers where the value is sourced from an environment variable. + #[serde(default, skip_serializing_if = "Option::is_none")] + env_http_headers: Option>, + }, +} + +mod option_duration_secs { + use serde::Deserialize; + use serde::Deserializer; + use serde::Serializer; + use std::time::Duration; + + pub fn serialize(value: &Option, serializer: S) -> Result + where + S: Serializer, + { + match value { + Some(duration) => serializer.serialize_some(&duration.as_secs_f64()), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let secs = Option::::deserialize(deserializer)?; + secs.map(|secs| Duration::try_from_secs_f64(secs).map_err(serde::de::Error::custom)) + .transpose() + } +} + +#[cfg(test)] +#[path = "mcp_types_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/config/types_tests.rs b/codex-rs/config/src/mcp_types_tests.rs similarity index 88% rename from codex-rs/core/src/config/types_tests.rs rename to codex-rs/config/src/mcp_types_tests.rs index dde0cd18fc..694314e6e2 100644 --- a/codex-rs/core/src/config/types_tests.rs +++ b/codex-rs/config/src/mcp_types_tests.rs @@ -1,5 +1,7 @@ use super::*; use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::path::PathBuf; #[test] fn deserialize_stdio_command_server_config() { @@ -244,42 +246,35 @@ fn deserialize_server_config_with_tool_filters() { } #[test] -fn deserialize_skill_config_with_name_selector() { - let cfg: SkillConfig = toml::from_str( +fn deserialize_ignores_unknown_server_fields() { + let cfg: McpServerConfig = toml::from_str( r#" - name = "github:yeet" - enabled = false + command = "echo" + trust_level = "trusted" "#, ) - .expect("should deserialize skill config with name selector"); - - assert_eq!(cfg.name.as_deref(), Some("github:yeet")); - assert_eq!(cfg.path, None); - assert!(!cfg.enabled); -} - -#[test] -fn deserialize_skill_config_with_path_selector() { - let tempdir = tempfile::tempdir().expect("tempdir"); - let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md"); - let cfg: SkillConfig = toml::from_str(&format!( - r#" - path = {path:?} - enabled = false - "#, - path = skill_path.display().to_string(), - )) - .expect("should deserialize skill config with path selector"); + .expect("should ignore unknown server fields"); assert_eq!( cfg, - SkillConfig { - path: Some( - AbsolutePathBuf::from_absolute_path(&skill_path) - .expect("skill path should be absolute"), - ), - name: None, - enabled: false, + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: Vec::new(), + cwd: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + tools: HashMap::new(), } ); } diff --git a/codex-rs/config/src/permissions_toml.rs b/codex-rs/config/src/permissions_toml.rs new file mode 100644 index 0000000000..fcc3e006b1 --- /dev/null +++ b/codex-rs/config/src/permissions_toml.rs @@ -0,0 +1,240 @@ +use std::collections::BTreeMap; + +use codex_network_proxy::NetworkDomainPermission as ProxyNetworkDomainPermission; +use codex_network_proxy::NetworkMode; +use codex_network_proxy::NetworkProxyConfig; +use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission; +use codex_network_proxy::normalize_host; +use codex_protocol::permissions::FileSystemAccessMode; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct PermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl PermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PermissionProfileToml { + pub filesystem: Option, + pub network: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct FilesystemPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl FilesystemPermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(untagged)] +pub enum FilesystemPermissionToml { + Access(FileSystemAccessMode), + Scoped(BTreeMap), +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct NetworkDomainPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl NetworkDomainPermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn allowed_domains(&self) -> Option> { + let allowed_domains: Vec = self + .entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Allow)) + .map(|(pattern, _)| pattern.clone()) + .collect(); + (!allowed_domains.is_empty()).then_some(allowed_domains) + } + + pub fn denied_domains(&self) -> Option> { + let denied_domains: Vec = self + .entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Deny)) + .map(|(pattern, _)| pattern.clone()) + .collect(); + (!denied_domains.is_empty()).then_some(denied_domains) + } +} + +#[derive( + Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema, +)] +#[serde(rename_all = "lowercase")] +pub enum NetworkDomainPermissionToml { + Allow, + Deny, +} + +impl std::fmt::Display for NetworkDomainPermissionToml { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let permission = match self { + Self::Allow => "allow", + Self::Deny => "deny", + }; + f.write_str(permission) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct NetworkUnixSocketPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl NetworkUnixSocketPermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn allow_unix_sockets(&self) -> Vec { + self.entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkUnixSocketPermissionToml::Allow)) + .map(|(path, _)| path.clone()) + .collect() + } +} + +#[derive( + Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema, +)] +#[serde(rename_all = "lowercase")] +pub enum NetworkUnixSocketPermissionToml { + Allow, + None, +} + +impl std::fmt::Display for NetworkUnixSocketPermissionToml { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let permission = match self { + Self::Allow => "allow", + Self::None => "none", + }; + f.write_str(permission) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct NetworkToml { + pub enabled: Option, + pub proxy_url: Option, + pub enable_socks5: Option, + pub socks_url: Option, + pub enable_socks5_udp: Option, + pub allow_upstream_proxy: Option, + pub dangerously_allow_non_loopback_proxy: Option, + pub dangerously_allow_all_unix_sockets: Option, + #[schemars(with = "Option")] + pub mode: Option, + pub domains: Option, + pub unix_sockets: Option, + pub allow_local_binding: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "lowercase")] +enum NetworkModeSchema { + Limited, + Full, +} + +impl NetworkToml { + pub fn apply_to_network_proxy_config(&self, config: &mut NetworkProxyConfig) { + if let Some(enabled) = self.enabled { + config.network.enabled = enabled; + } + if let Some(proxy_url) = self.proxy_url.as_ref() { + config.network.proxy_url = proxy_url.clone(); + } + if let Some(enable_socks5) = self.enable_socks5 { + config.network.enable_socks5 = enable_socks5; + } + if let Some(socks_url) = self.socks_url.as_ref() { + config.network.socks_url = socks_url.clone(); + } + if let Some(enable_socks5_udp) = self.enable_socks5_udp { + config.network.enable_socks5_udp = enable_socks5_udp; + } + if let Some(allow_upstream_proxy) = self.allow_upstream_proxy { + config.network.allow_upstream_proxy = allow_upstream_proxy; + } + if let Some(dangerously_allow_non_loopback_proxy) = + self.dangerously_allow_non_loopback_proxy + { + config.network.dangerously_allow_non_loopback_proxy = + dangerously_allow_non_loopback_proxy; + } + if let Some(dangerously_allow_all_unix_sockets) = self.dangerously_allow_all_unix_sockets { + config.network.dangerously_allow_all_unix_sockets = dangerously_allow_all_unix_sockets; + } + if let Some(mode) = self.mode { + config.network.mode = mode; + } + if let Some(domains) = self.domains.as_ref() { + overlay_network_domain_permissions(config, domains); + } + if let Some(unix_sockets) = self.unix_sockets.as_ref() { + let mut proxy_unix_sockets = config.network.unix_sockets.take().unwrap_or_default(); + for (path, permission) in &unix_sockets.entries { + let permission = match permission { + NetworkUnixSocketPermissionToml::Allow => { + ProxyNetworkUnixSocketPermission::Allow + } + NetworkUnixSocketPermissionToml::None => ProxyNetworkUnixSocketPermission::None, + }; + proxy_unix_sockets.entries.insert(path.clone(), permission); + } + config.network.unix_sockets = + (!proxy_unix_sockets.entries.is_empty()).then_some(proxy_unix_sockets); + } + if let Some(allow_local_binding) = self.allow_local_binding { + config.network.allow_local_binding = allow_local_binding; + } + } + + pub fn to_network_proxy_config(&self) -> NetworkProxyConfig { + let mut config = NetworkProxyConfig::default(); + self.apply_to_network_proxy_config(&mut config); + config + } +} + +pub fn overlay_network_domain_permissions( + config: &mut NetworkProxyConfig, + domains: &NetworkDomainPermissionsToml, +) { + for (pattern, permission) in &domains.entries { + let permission = match permission { + NetworkDomainPermissionToml::Allow => ProxyNetworkDomainPermission::Allow, + NetworkDomainPermissionToml::Deny => ProxyNetworkDomainPermission::Deny, + }; + config + .network + .upsert_domain_permission(pattern.clone(), permission, normalize_host); + } +} diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/config/src/profile_toml.rs similarity index 87% rename from codex-rs/core/src/config/profile.rs rename to codex-rs/config/src/profile_toml.rs index e0947302e9..69215c0440 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/config/src/profile_toml.rs @@ -3,11 +3,11 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; -use crate::config::ToolsToml; -use crate::config::types::ApprovalsReviewer; -use crate::config::types::Personality; -use crate::config::types::WindowsToml; -use crate::protocol::AskForApproval; +use crate::config_toml::ToolsToml; +use crate::types::AnalyticsConfigToml; +use crate::types::ApprovalsReviewer; +use crate::types::Personality; +use crate::types::WindowsToml; use codex_features::FeaturesToml; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; @@ -15,6 +15,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Verbosity; use codex_protocol::config_types::WebSearchMode; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; /// Collection of common configuration options that a user can define as a unit /// in `config.toml`. @@ -49,18 +50,21 @@ pub struct ConfigProfile { pub experimental_instructions_file: Option, pub experimental_compact_prompt_file: Option, pub include_apply_patch_tool: Option, + pub include_permissions_instructions: Option, + pub include_apps_instructions: Option, + pub include_environment_context: Option, pub experimental_use_unified_exec_tool: Option, pub experimental_use_freeform_apply_patch: Option, pub tools_view_image: Option, pub tools: Option, pub web_search: Option, - pub analytics: Option, + pub analytics: Option, #[serde(default)] pub windows: Option, /// Optional feature toggles scoped to this profile. #[serde(default)] // Injects known feature keys into the schema and forbids unknown keys. - #[schemars(schema_with = "crate::config::schema::features_schema")] + #[schemars(schema_with = "crate::schema::features_schema")] pub features: Option, pub oss_provider: Option, } diff --git a/codex-rs/config/src/requirements_exec_policy.rs b/codex-rs/config/src/requirements_exec_policy.rs index 64d60f8814..30a1a0e694 100644 --- a/codex-rs/config/src/requirements_exec_policy.rs +++ b/codex-rs/config/src/requirements_exec_policy.rs @@ -1,9 +1,9 @@ use codex_execpolicy::Decision; +use codex_execpolicy::PatternToken; use codex_execpolicy::Policy; -use codex_execpolicy::rule::PatternToken; -use codex_execpolicy::rule::PrefixPattern; -use codex_execpolicy::rule::PrefixRule; -use codex_execpolicy::rule::RuleRef; +use codex_execpolicy::PrefixPattern; +use codex_execpolicy::PrefixRule; +use codex_execpolicy::RuleRef; use multimap::MultiMap; use serde::Deserialize; use std::sync::Arc; diff --git a/codex-rs/config/src/schema.rs b/codex-rs/config/src/schema.rs new file mode 100644 index 0000000000..72252d7a21 --- /dev/null +++ b/codex-rs/config/src/schema.rs @@ -0,0 +1,100 @@ +use crate::config_toml::ConfigToml; +use crate::types::RawMcpServerConfig; +use codex_features::FEATURES; +use codex_features::legacy_feature_keys; +use schemars::r#gen::SchemaGenerator; +use schemars::r#gen::SchemaSettings; +use schemars::schema::InstanceType; +use schemars::schema::ObjectValidation; +use schemars::schema::RootSchema; +use schemars::schema::Schema; +use schemars::schema::SchemaObject; +use serde_json::Map; +use serde_json::Value; +use std::path::Path; + +/// Schema for the `[features]` map with known + legacy keys only. +pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { + let mut object = SchemaObject { + instance_type: Some(InstanceType::Object.into()), + ..Default::default() + }; + + let mut validation = ObjectValidation::default(); + for feature in FEATURES { + if feature.id == codex_features::Feature::Artifact { + continue; + } + validation + .properties + .insert(feature.key.to_string(), schema_gen.subschema_for::()); + } + for legacy_key in legacy_feature_keys() { + validation + .properties + .insert(legacy_key.to_string(), schema_gen.subschema_for::()); + } + validation.additional_properties = Some(Box::new(Schema::Bool(false))); + object.object = Some(Box::new(validation)); + + Schema::Object(object) +} + +/// Schema for the `[mcp_servers]` map using the raw input shape. +pub fn mcp_servers_schema(schema_gen: &mut SchemaGenerator) -> Schema { + let mut object = SchemaObject { + instance_type: Some(InstanceType::Object.into()), + ..Default::default() + }; + + let validation = ObjectValidation { + additional_properties: Some(Box::new(schema_gen.subschema_for::())), + ..Default::default() + }; + object.object = Some(Box::new(validation)); + + Schema::Object(object) +} + +/// Build the config schema for `config.toml`. +pub fn config_schema() -> RootSchema { + SchemaSettings::draft07() + .with(|settings| { + settings.option_add_null_type = false; + }) + .into_generator() + .into_root_schema_for::() +} + +/// Canonicalize a JSON value by sorting its keys. +pub fn canonicalize(value: &Value) -> Value { + match value { + Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()), + Value::Object(map) => { + let mut entries: Vec<_> = map.iter().collect(); + entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + let mut sorted = Map::with_capacity(map.len()); + for (key, child) in entries { + sorted.insert(key.clone(), canonicalize(child)); + } + Value::Object(sorted) + } + _ => value.clone(), + } +} + +/// Render the config schema as pretty-printed JSON. +pub fn config_schema_json() -> anyhow::Result> { + let schema = config_schema(); + let value = serde_json::to_value(schema)?; + let value = canonicalize(&value); + let json = serde_json::to_vec_pretty(&value)?; + Ok(json) +} + +/// Write the config schema fixture to disk. +pub fn write_config_schema(out_path: &Path) -> anyhow::Result<()> { + let json = config_schema_json()?; + std::fs::write(out_path, json)?; + Ok(()) +} diff --git a/codex-rs/config/src/state.rs b/codex-rs/config/src/state.rs index f6899651b1..a34ccc6cdb 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -23,6 +23,31 @@ pub struct LoaderOverrides { pub macos_managed_config_requirements_base64: Option, } +impl LoaderOverrides { + /// Returns overrides that ignore host-managed configuration. + /// + /// 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"), + ) + } + + /// Returns overrides with host MDM disabled and managed config loaded from `managed_config_path`. + /// + /// This is intended for tests that supply an explicit managed config fixture. + pub fn with_managed_config_path_for_tests(managed_config_path: PathBuf) -> Self { + Self { + managed_config_path: Some(managed_config_path), + #[cfg(target_os = "macos")] + managed_preferences_base64: Some(String::new()), + macos_managed_config_requirements_base64: Some(String::new()), + } + } +} + #[derive(Debug, Clone, PartialEq)] pub struct ConfigLayerEntry { pub name: ConfigLayerSource, diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/config/src/types.rs similarity index 70% rename from codex-rs/core/src/config/types.rs rename to codex-rs/config/src/types.rs index c9f83482aa..f26d336018 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/config/src/types.rs @@ -1,9 +1,14 @@ -//! Types used to define the fields of [`crate::config::Config`]. +//! Types used to define loaded and effective Codex configuration values. // Note this file should generally be restricted to simple struct/enum // definitions that do not contain business logic. -use crate::config_loader::RequirementSource; +pub use crate::mcp_types::AppToolApproval; +pub use crate::mcp_types::McpServerConfig; +pub use crate::mcp_types::McpServerDisabledReason; +pub use crate::mcp_types::McpServerToolConfig; +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; pub use codex_protocol::config_types::ModeKind; @@ -14,15 +19,11 @@ use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::BTreeMap; use std::collections::HashMap; use std::fmt; -use std::path::PathBuf; -use std::time::Duration; use wildmatch::WildMatchPattern; use schemars::JsonSchema; use serde::Deserialize; -use serde::Deserializer; use serde::Serialize; -use serde::de::Error as SerdeError; pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev"; pub const DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 16; @@ -31,6 +32,40 @@ pub const DEFAULT_MEMORIES_MIN_ROLLOUT_IDLE_HOURS: i64 = 6; pub const DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION: usize = 256; pub const DEFAULT_MEMORIES_MAX_UNUSED_DAYS: i64 = 30; +const fn default_enabled() -> bool { + true +} + +/// Determine where Codex should store CLI auth credentials. +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum AuthCredentialsStoreMode { + #[default] + /// Persist credentials in CODEX_HOME/auth.json. + File, + /// Persist credentials in the keyring. Fail if unavailable. + Keyring, + /// Use keyring when available; otherwise, fall back to a file in CODEX_HOME. + Auto, + /// Store credentials in memory only for the current process. + Ephemeral, +} + +/// Determine where Codex should store and read MCP credentials. +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum OAuthCredentialsStoreMode { + /// `Keyring` when available; otherwise, `File`. + /// Credentials stored in the keyring will only be readable by Codex unless the user explicitly grants access via OS-level keyring access. + #[default] + Auto, + /// CODEX_HOME/.credentials.json + /// This file will be readable to Codex and other applications running as the same user. + File, + /// Keyring when available, otherwise fail. + Keyring, +} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "kebab-case")] pub enum WindowsSandboxModeToml { @@ -47,261 +82,6 @@ pub struct WindowsToml { pub sandbox_private_desktop: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum McpServerDisabledReason { - Unknown, - Requirements { source: RequirementSource }, -} - -impl fmt::Display for McpServerDisabledReason { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - McpServerDisabledReason::Unknown => write!(f, "unknown"), - McpServerDisabledReason::Requirements { source } => { - write!(f, "requirements ({source})") - } - } - } -} - -#[derive(Serialize, Debug, Clone, PartialEq)] -pub struct McpServerConfig { - #[serde(flatten)] - pub transport: McpServerTransportConfig, - - /// When `false`, Codex skips initializing this MCP server. - #[serde(default = "default_enabled")] - pub enabled: bool, - - /// When `true`, `codex exec` exits with an error if this MCP server fails to initialize. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub required: bool, - - /// Reason this server was disabled after applying requirements. - #[serde(skip)] - pub disabled_reason: Option, - - /// Startup timeout in seconds for initializing MCP server & initially listing tools. - #[serde( - default, - with = "option_duration_secs", - skip_serializing_if = "Option::is_none" - )] - pub startup_timeout_sec: Option, - - /// Default timeout for MCP tool calls initiated via this server. - #[serde(default, with = "option_duration_secs")] - pub tool_timeout_sec: Option, - - /// Explicit allow-list of tools exposed from this server. When set, only these tools will be registered. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub enabled_tools: Option>, - - /// Explicit deny-list of tools. These tools will be removed after applying `enabled_tools`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub disabled_tools: Option>, - - /// Optional OAuth scopes to request during MCP login. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub scopes: Option>, - - /// Optional OAuth resource parameter to include during MCP login (RFC 8707). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub oauth_resource: Option, -} - -// Raw MCP config shape used for deserialization and JSON Schema generation. -// Keep this in sync with the validation logic in `McpServerConfig`. -#[derive(Deserialize, Clone, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub(crate) struct RawMcpServerConfig { - // stdio - pub command: Option, - #[serde(default)] - pub args: Option>, - #[serde(default)] - pub env: Option>, - #[serde(default)] - pub env_vars: Option>, - #[serde(default)] - pub cwd: Option, - pub http_headers: Option>, - #[serde(default)] - pub env_http_headers: Option>, - - // streamable_http - pub url: Option, - pub bearer_token: Option, - pub bearer_token_env_var: Option, - - // shared - #[serde(default)] - pub startup_timeout_sec: Option, - #[serde(default)] - pub startup_timeout_ms: Option, - #[serde(default, with = "option_duration_secs")] - #[schemars(with = "Option")] - pub tool_timeout_sec: Option, - #[serde(default)] - pub enabled: Option, - #[serde(default)] - pub required: Option, - #[serde(default)] - pub enabled_tools: Option>, - #[serde(default)] - pub disabled_tools: Option>, - #[serde(default)] - pub scopes: Option>, - #[serde(default)] - pub oauth_resource: Option, -} - -impl<'de> Deserialize<'de> for McpServerConfig { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let mut raw = RawMcpServerConfig::deserialize(deserializer)?; - - let startup_timeout_sec = match (raw.startup_timeout_sec, raw.startup_timeout_ms) { - (Some(sec), _) => { - let duration = Duration::try_from_secs_f64(sec).map_err(SerdeError::custom)?; - Some(duration) - } - (None, Some(ms)) => Some(Duration::from_millis(ms)), - (None, None) => None, - }; - let tool_timeout_sec = raw.tool_timeout_sec; - let enabled = raw.enabled.unwrap_or_else(default_enabled); - let required = raw.required.unwrap_or_default(); - let enabled_tools = raw.enabled_tools.clone(); - let disabled_tools = raw.disabled_tools.clone(); - let scopes = raw.scopes.clone(); - let oauth_resource = raw.oauth_resource.clone(); - - fn throw_if_set(transport: &str, field: &str, value: Option<&T>) -> Result<(), E> - where - E: SerdeError, - { - if value.is_none() { - return Ok(()); - } - Err(E::custom(format!( - "{field} is not supported for {transport}", - ))) - } - - let transport = if let Some(command) = raw.command.clone() { - throw_if_set("stdio", "url", raw.url.as_ref())?; - throw_if_set( - "stdio", - "bearer_token_env_var", - raw.bearer_token_env_var.as_ref(), - )?; - throw_if_set("stdio", "bearer_token", raw.bearer_token.as_ref())?; - throw_if_set("stdio", "http_headers", raw.http_headers.as_ref())?; - throw_if_set("stdio", "env_http_headers", raw.env_http_headers.as_ref())?; - throw_if_set("stdio", "oauth_resource", raw.oauth_resource.as_ref())?; - McpServerTransportConfig::Stdio { - command, - args: raw.args.clone().unwrap_or_default(), - env: raw.env.clone(), - env_vars: raw.env_vars.clone().unwrap_or_default(), - cwd: raw.cwd.take(), - } - } else if let Some(url) = raw.url.clone() { - throw_if_set("streamable_http", "args", raw.args.as_ref())?; - throw_if_set("streamable_http", "env", raw.env.as_ref())?; - throw_if_set("streamable_http", "env_vars", raw.env_vars.as_ref())?; - throw_if_set("streamable_http", "cwd", raw.cwd.as_ref())?; - throw_if_set("streamable_http", "bearer_token", raw.bearer_token.as_ref())?; - McpServerTransportConfig::StreamableHttp { - url, - bearer_token_env_var: raw.bearer_token_env_var.clone(), - http_headers: raw.http_headers.clone(), - env_http_headers: raw.env_http_headers.take(), - } - } else { - return Err(SerdeError::custom("invalid transport")); - }; - - Ok(Self { - transport, - startup_timeout_sec, - tool_timeout_sec, - enabled, - required, - disabled_reason: None, - enabled_tools, - disabled_tools, - scopes, - oauth_resource, - }) - } -} - -const fn default_enabled() -> bool { - true -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] -#[serde(untagged, deny_unknown_fields, rename_all = "snake_case")] -pub enum McpServerTransportConfig { - /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio - Stdio { - command: String, - #[serde(default)] - args: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - env: Option>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - env_vars: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - cwd: Option, - }, - /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http - StreamableHttp { - url: String, - /// Name of the environment variable to read for an HTTP bearer token. - /// When set, requests will include the token via `Authorization: Bearer `. - /// The actual secret value must be provided via the environment. - #[serde(default, skip_serializing_if = "Option::is_none")] - bearer_token_env_var: Option, - /// Additional HTTP headers to include in requests to this server. - #[serde(default, skip_serializing_if = "Option::is_none")] - http_headers: Option>, - /// HTTP headers where the value is sourced from an environment variable. - #[serde(default, skip_serializing_if = "Option::is_none")] - env_http_headers: Option>, - }, -} - -mod option_duration_secs { - use serde::Deserialize; - use serde::Deserializer; - use serde::Serializer; - use std::time::Duration; - - pub fn serialize(value: &Option, serializer: S) -> Result - where - S: Serializer, - { - match value { - Some(duration) => serializer.serialize_some(&duration.as_secs_f64()), - None => serializer.serialize_none(), - } - } - - pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> - where - D: Deserializer<'de>, - { - let secs = Option::::deserialize(deserializer)?; - secs.map(|secs| Duration::try_from_secs_f64(secs).map_err(serde::de::Error::custom)) - .transpose() - } -} - #[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, JsonSchema)] pub enum UriBasedFileOpener { #[serde(rename = "vscode")] @@ -487,15 +267,6 @@ impl From for MemoriesConfig { } } -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum AppToolApproval { - #[default] - Auto, - Prompt, - Approve, -} - /// Default settings that apply to all apps. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] @@ -796,14 +567,9 @@ pub struct Notice { pub model_migrations: BTreeMap, } -impl Notice { - /// referenced by config_edit helpers when writing notice flags - pub(crate) const TABLE_KEY: &'static str = "notice"; -} - -pub use codex_config::BundledSkillsConfig; -pub use codex_config::SkillConfig; -pub use codex_config::SkillsConfig; +pub use crate::skills_config::BundledSkillsConfig; +pub use crate::skills_config::SkillConfig; +pub use crate::skills_config::SkillsConfig; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] diff --git a/codex-rs/config/src/types_tests.rs b/codex-rs/config/src/types_tests.rs new file mode 100644 index 0000000000..fbcd2bdc64 --- /dev/null +++ b/codex-rs/config/src/types_tests.rs @@ -0,0 +1,43 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn deserialize_skill_config_with_name_selector() { + let cfg: SkillConfig = toml::from_str( + r#" + name = "github:yeet" + enabled = false + "#, + ) + .expect("should deserialize skill config with name selector"); + + assert_eq!(cfg.name.as_deref(), Some("github:yeet")); + assert_eq!(cfg.path, None); + assert!(!cfg.enabled); +} + +#[test] +fn deserialize_skill_config_with_path_selector() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md"); + let cfg: SkillConfig = toml::from_str(&format!( + r#" + path = {path:?} + enabled = false + "#, + path = skill_path.display().to_string(), + )) + .expect("should deserialize skill config with path selector"); + + assert_eq!( + cfg, + SkillConfig { + path: Some( + AbsolutePathBuf::from_absolute_path(&skill_path) + .expect("skill path should be absolute"), + ), + name: None, + enabled: false, + } + ); +} diff --git a/codex-rs/connectors/src/lib.rs b/codex-rs/connectors/src/lib.rs index 1d3a729233..a74ece5a35 100644 --- a/codex-rs/connectors/src/lib.rs +++ b/codex-rs/connectors/src/lib.rs @@ -419,7 +419,7 @@ mod tests { "https://chatgpt.example".to_string(), Some(format!("account-{id}")), Some(format!("user-{id}")), - true, + /*is_workspace_account*/ true, ) } @@ -444,21 +444,31 @@ mod tests { let call_counter = Arc::clone(&calls); let key = cache_key("shared"); - let first = list_all_connectors_with_options(key.clone(), false, false, move |_path| { - let call_counter = Arc::clone(&call_counter); - async move { - call_counter.fetch_add(1, Ordering::SeqCst); - Ok(DirectoryListResponse { - apps: vec![app("alpha", "Alpha")], - next_token: None, - }) - } - }) + let first = list_all_connectors_with_options( + key.clone(), + /*is_workspace_account*/ false, + /*force_refetch*/ false, + move |_path| { + let call_counter = Arc::clone(&call_counter); + async move { + call_counter.fetch_add(1, Ordering::SeqCst); + Ok(DirectoryListResponse { + apps: vec![app("alpha", "Alpha")], + next_token: None, + }) + } + }, + ) .await?; - let second = list_all_connectors_with_options(key, false, false, move |_path| async move { - anyhow::bail!("cache should have been used"); - }) + let second = list_all_connectors_with_options( + key, + /*is_workspace_account*/ false, + /*force_refetch*/ false, + move |_path| async move { + anyhow::bail!("cache should have been used"); + }, + ) .await?; assert_eq!(calls.load(Ordering::SeqCst), 1); @@ -472,40 +482,45 @@ mod tests { let calls = Arc::new(AtomicUsize::new(0)); let call_counter = Arc::clone(&calls); - let connectors = list_all_connectors_with_options(key, true, true, move |path| { - let call_counter = Arc::clone(&call_counter); - async move { - call_counter.fetch_add(1, Ordering::SeqCst); - if path.starts_with("/connectors/directory/list_workspace") { - Ok(DirectoryListResponse { - apps: vec![ - DirectoryApp { - description: Some("Merged description".to_string()), - branding: Some(AppBranding { - category: Some("calendar".to_string()), - developer: None, - website: None, - privacy_policy: None, - terms_of_service: None, - is_discoverable_app: true, - }), - ..app("alpha", "") - }, - DirectoryApp { - visibility: Some("HIDDEN".to_string()), - ..app("hidden", "Hidden") - }, - ], - next_token: None, - }) - } else { - Ok(DirectoryListResponse { - apps: vec![app("alpha", " Alpha "), app("beta", "Beta")], - next_token: None, - }) + let connectors = list_all_connectors_with_options( + key, + /*is_workspace_account*/ true, + /*force_refetch*/ true, + move |path| { + let call_counter = Arc::clone(&call_counter); + async move { + call_counter.fetch_add(1, Ordering::SeqCst); + if path.starts_with("/connectors/directory/list_workspace") { + Ok(DirectoryListResponse { + apps: vec![ + DirectoryApp { + description: Some("Merged description".to_string()), + branding: Some(AppBranding { + category: Some("calendar".to_string()), + developer: None, + website: None, + privacy_policy: None, + terms_of_service: None, + is_discoverable_app: true, + }), + ..app("alpha", "") + }, + DirectoryApp { + visibility: Some("HIDDEN".to_string()), + ..app("hidden", "Hidden") + }, + ], + next_token: None, + }) + } else { + Ok(DirectoryListResponse { + apps: vec![app("alpha", " Alpha "), app("beta", "Beta")], + next_token: None, + }) + } } - } - }) + }, + ) .await?; assert_eq!(calls.load(Ordering::SeqCst), 2); diff --git a/codex-rs/core-skills/src/injection_tests.rs b/codex-rs/core-skills/src/injection_tests.rs index 8d66a0af57..b8611de4ef 100644 --- a/codex-rs/core-skills/src/injection_tests.rs +++ b/codex-rs/core-skills/src/injection_tests.rs @@ -11,8 +11,6 @@ fn make_skill(name: &str, path: &str) -> SkillMetadata { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: PathBuf::from(path), scope: codex_protocol::protocol::SkillScope::User, } diff --git a/codex-rs/core-skills/src/invocation_utils_tests.rs b/codex-rs/core-skills/src/invocation_utils_tests.rs index 657582b742..6d74dbe9a7 100644 --- a/codex-rs/core-skills/src/invocation_utils_tests.rs +++ b/codex-rs/core-skills/src/invocation_utils_tests.rs @@ -18,8 +18,6 @@ fn test_skill_metadata(skill_doc_path: PathBuf) -> SkillMetadata { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: skill_doc_path, scope: codex_protocol::protocol::SkillScope::User, } diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index b6ef000845..42de9fb288 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -2,7 +2,6 @@ use crate::model::SkillDependencies; use crate::model::SkillError; use crate::model::SkillInterface; use crate::model::SkillLoadOutcome; -use crate::model::SkillManagedNetworkOverride; use crate::model::SkillMetadata; use crate::model::SkillPolicy; use crate::model::SkillToolDependency; @@ -13,10 +12,6 @@ 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_protocol::models::FileSystemPermissions; -use codex_protocol::models::MacOsSeatbeltProfileExtensions; -use codex_protocol::models::NetworkPermissions; -use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBufGuard; @@ -59,8 +54,6 @@ struct SkillMetadataFile { dependencies: Option, #[serde(default)] policy: Option, - #[serde(default)] - permissions: Option, } #[derive(Default)] @@ -68,28 +61,6 @@ struct LoadedSkillMetadata { interface: Option, dependencies: Option, policy: Option, - permission_profile: Option, - managed_network_override: Option, -} - -#[derive(Debug, Default, Deserialize, PartialEq, Eq)] -struct SkillPermissionProfile { - #[serde(default)] - network: Option, - #[serde(default)] - file_system: Option, - #[serde(default)] - macos: Option, -} - -#[derive(Debug, Default, Deserialize, PartialEq, Eq)] -struct SkillNetworkPermissions { - #[serde(default)] - enabled: Option, - #[serde(default)] - allowed_domains: Option>, - #[serde(default)] - denied_domains: Option>, } #[derive(Debug, Default, Deserialize)] @@ -551,8 +522,6 @@ fn parse_skill_file(path: &Path, scope: SkillScope) -> Result Result LoadedSkillMetadata { interface, dependencies, policy, - permissions, } = parsed; - let (permission_profile, managed_network_override) = normalize_permissions(permissions); LoadedSkillMetadata { interface: resolve_interface(interface, skill_dir), dependencies: resolve_dependencies(dependencies), policy: resolve_policy(policy), - permission_profile, - managed_network_override, } } -fn normalize_permissions( - permissions: Option, -) -> ( - Option, - Option, -) { - let Some(permissions) = permissions else { - return (None, None); - }; - let managed_network_override = permissions - .network - .as_ref() - .map(|network| SkillManagedNetworkOverride { - allowed_domains: network.allowed_domains.clone(), - denied_domains: network.denied_domains.clone(), - }) - .filter(SkillManagedNetworkOverride::has_domain_overrides); - let permission_profile = PermissionProfile { - network: permissions.network.and_then(|network| { - let network = NetworkPermissions { - enabled: network.enabled, - }; - (!network.is_empty()).then_some(network) - }), - file_system: permissions - .file_system - .filter(|file_system| !file_system.is_empty()), - macos: permissions.macos, - }; - - ( - (!permission_profile.is_empty()).then_some(permission_profile), - managed_network_override, - ) -} - fn resolve_interface(interface: Option, skill_dir: &Path) -> Option { let interface = interface?; let interface = SkillInterface { diff --git a/codex-rs/core-skills/src/loader_tests.rs b/codex-rs/core-skills/src/loader_tests.rs index 5875b1a043..3702856306 100644 --- a/codex-rs/core-skills/src/loader_tests.rs +++ b/codex-rs/core-skills/src/loader_tests.rs @@ -4,12 +4,6 @@ use codex_config::ConfigLayerEntry; use codex_config::ConfigLayerStack; use codex_config::ConfigRequirements; use codex_config::ConfigRequirementsToml; -use codex_protocol::models::FileSystemPermissions; -use codex_protocol::models::MacOsAutomationPermission; -use codex_protocol::models::MacOsContactsPermission; -use codex_protocol::models::MacOsPreferencesPermission; -use codex_protocol::models::MacOsSeatbeltProfileExtensions; -use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; @@ -121,7 +115,7 @@ fn load_skills_for_test(config: &TestConfig) -> SkillLoadOutcome { super::load_skills_from_roots(super::skill_roots_with_home_dir( &config.config_layer_stack, &config.cwd, - None, + /*home_dir*/ None, Vec::new(), )) } @@ -290,8 +284,6 @@ fn loads_skills_from_home_agents_dir_for_user_scope() -> anyhow::Result<()> { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -442,8 +434,6 @@ async fn loads_skill_dependencies_metadata_from_yaml() { ], }), policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -499,8 +489,6 @@ interface: }), dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(skill_path.as_path()), scope: SkillScope::User, }] @@ -610,340 +598,6 @@ policy: ); } -#[tokio::test] -async fn loads_skill_permissions_from_yaml() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let skill_path = write_skill(&codex_home, "demo", "permissions-skill", "from yaml"); - let skill_dir = skill_path.parent().expect("skill dir"); - fs::create_dir_all(skill_dir.join("data")).expect("create read path"); - fs::create_dir_all(skill_dir.join("output")).expect("create write path"); - - write_skill_metadata_at( - skill_dir, - r#" -permissions: - network: - enabled: true - file_system: - read: - - "./data" - write: - - "./output" -"#, - ); - - let cfg = make_config(&codex_home).await; - let outcome = load_skills_for_test(&cfg); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!(outcome.skills.len(), 1); - assert_eq!( - outcome.skills[0].permission_profile, - Some(PermissionProfile { - network: Some(NetworkPermissions { - enabled: Some(true), - }), - file_system: Some(FileSystemPermissions { - read: Some(vec![ - AbsolutePathBuf::try_from(normalized(skill_dir.join("data").as_path())) - .expect("absolute data path"), - ]), - write: Some(vec![ - AbsolutePathBuf::try_from(normalized(skill_dir.join("output").as_path())) - .expect("absolute output path"), - ]), - }), - macos: None, - }) - ); - assert_eq!(outcome.skills[0].managed_network_override, None); -} - -#[tokio::test] -async fn empty_skill_permissions_do_not_create_profile() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let skill_path = write_skill(&codex_home, "demo", "permissions-empty", "from yaml"); - let skill_dir = skill_path.parent().expect("skill dir"); - - write_skill_metadata_at( - skill_dir, - r#" -permissions: {} -"#, - ); - - let cfg = make_config(&codex_home).await; - let outcome = load_skills_for_test(&cfg); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!(outcome.skills.len(), 1); - assert_eq!(outcome.skills[0].permission_profile, None); -} - -#[test] -fn normalize_permissions_splits_managed_network_overrides() { - let (permission_profile, managed_network_override) = - normalize_permissions(Some(SkillPermissionProfile { - network: Some(SkillNetworkPermissions { - enabled: Some(true), - allowed_domains: Some(vec!["skill.example.com".to_string()]), - denied_domains: Some(vec!["blocked.skill.example.com".to_string()]), - }), - file_system: None, - macos: None, - })); - - assert_eq!( - permission_profile, - Some(PermissionProfile { - network: Some(NetworkPermissions { - enabled: Some(true), - }), - file_system: None, - macos: None, - }) - ); - assert_eq!( - managed_network_override, - Some(SkillManagedNetworkOverride { - allowed_domains: Some(vec!["skill.example.com".to_string()]), - denied_domains: Some(vec!["blocked.skill.example.com".to_string()]), - }) - ); -} - -#[test] -fn normalize_permissions_preserves_network_gate_separately_from_overrides() { - let (permission_profile, managed_network_override) = - normalize_permissions(Some(SkillPermissionProfile { - network: Some(SkillNetworkPermissions { - enabled: Some(false), - allowed_domains: Some(vec!["skill.example.com".to_string()]), - denied_domains: None, - }), - file_system: None, - macos: None, - })); - - assert_eq!( - permission_profile, - Some(PermissionProfile { - network: Some(NetworkPermissions { - enabled: Some(false), - }), - file_system: None, - macos: None, - }) - ); - assert_eq!( - managed_network_override, - Some(SkillManagedNetworkOverride { - allowed_domains: Some(vec!["skill.example.com".to_string()]), - denied_domains: None, - }) - ); -} - -#[test] -fn skill_metadata_parses_macos_permissions_yaml() { - let parsed = serde_yaml::from_str::( - r#" -permissions: - macos: - macos_preferences: "read_write" - macos_automation: - - "com.apple.Notes" - macos_launch_services: true - macos_accessibility: true - macos_calendar: true -"#, - ) - .expect("parse skill metadata"); - - assert_eq!( - parsed.permissions, - Some(SkillPermissionProfile { - network: None, - file_system: None, - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string(), - ]), - macos_launch_services: true, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, - }), - }) - ); -} - -#[test] -fn skill_metadata_parses_macos_reminders_permission_yaml() { - let parsed = serde_yaml::from_str::( - r#" -permissions: - macos: - macos_reminders: true -"#, - ) - .expect("parse reminders skill metadata"); - - assert_eq!( - parsed.permissions, - Some(SkillPermissionProfile { - network: None, - file_system: None, - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadOnly, - macos_automation: MacOsAutomationPermission::None, - macos_launch_services: false, - macos_accessibility: false, - macos_calendar: false, - macos_reminders: true, - macos_contacts: MacOsContactsPermission::None, - }), - }) - ); -} - -#[test] -fn skill_metadata_parses_network_domain_overrides_under_permissions() { - let parsed = serde_yaml::from_str::( - r#" -permissions: - network: - enabled: true - allowed_domains: - - "skill.example.com" - denied_domains: - - "blocked.skill.example.com" -"#, - ) - .expect("parse network skill metadata"); - - assert_eq!( - parsed.permissions, - Some(SkillPermissionProfile { - network: Some(SkillNetworkPermissions { - enabled: Some(true), - allowed_domains: Some(vec!["skill.example.com".to_string()]), - denied_domains: Some(vec!["blocked.skill.example.com".to_string()]), - }), - file_system: None, - macos: None, - }) - ); -} - -#[cfg(target_os = "macos")] -#[tokio::test] -async fn loads_skill_macos_permissions_from_yaml() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let skill_path = write_skill(&codex_home, "demo", "permissions-macos", "from yaml"); - let skill_dir = skill_path.parent().expect("skill dir"); - - write_skill_metadata_at( - skill_dir, - r#" -permissions: - macos: - macos_preferences: "read_write" - macos_automation: - - "com.apple.Notes" - macos_launch_services: true - macos_accessibility: true - macos_calendar: true -"#, - ); - - let cfg = make_config(&codex_home).await; - let outcome = load_skills_for_test(&cfg); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!(outcome.skills.len(), 1); - assert_eq!( - outcome.skills[0].permission_profile, - Some(PermissionProfile { - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string() - ],), - macos_launch_services: true, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, - }), - ..Default::default() - }) - ); -} - -#[cfg(not(target_os = "macos"))] -#[tokio::test] -async fn loads_skill_macos_permissions_from_yaml_non_macos_does_not_create_profile() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let skill_path = write_skill(&codex_home, "demo", "permissions-macos", "from yaml"); - let skill_dir = skill_path.parent().expect("skill dir"); - - write_skill_metadata_at( - skill_dir, - r#" -permissions: - macos: - macos_preferences: "read_write" - macos_automation: - - "com.apple.Notes" - macos_launch_services: true - macos_accessibility: true - macos_calendar: true -"#, - ); - - let cfg = make_config(&codex_home).await; - let outcome = load_skills_for_test(&cfg); - - assert!( - outcome.errors.is_empty(), - "unexpected errors: {:?}", - outcome.errors - ); - assert_eq!(outcome.skills.len(), 1); - assert_eq!( - outcome.skills[0].permission_profile, - Some(PermissionProfile { - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string() - ],), - macos_launch_services: true, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, - }), - ..Default::default() - }) - ); -} - #[tokio::test] async fn accepts_icon_paths_under_assets_dir() { let codex_home = tempfile::tempdir().expect("tempdir"); @@ -988,8 +642,6 @@ async fn accepts_icon_paths_under_assets_dir() { }), dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1030,8 +682,6 @@ async fn ignores_invalid_brand_color() { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1085,8 +735,6 @@ async fn ignores_default_prompt_over_max_length() { }), dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1128,8 +776,6 @@ async fn drops_interface_when_icons_are_invalid() { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1174,8 +820,6 @@ async fn loads_skills_via_symlinked_subdir_for_user_scope() { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(&shared_skill_path), scope: SkillScope::User, }] @@ -1235,8 +879,6 @@ async fn does_not_loop_on_symlink_cycle_for_user_scope() { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1273,8 +915,6 @@ fn loads_skills_via_symlinked_subdir_for_admin_scope() { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(&shared_skill_path), scope: SkillScope::Admin, }] @@ -1314,8 +954,6 @@ async fn loads_skills_via_symlinked_subdir_for_repo_scope() { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(&linked_skill_path), scope: SkillScope::Repo, }] @@ -1383,8 +1021,6 @@ async fn respects_max_scan_depth_for_user_scope() { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(&within_depth_path), scope: SkillScope::User, }] @@ -1412,8 +1048,6 @@ async fn loads_valid_skill() { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1446,8 +1080,6 @@ async fn falls_back_to_directory_name_when_skill_name_is_missing() { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1489,8 +1121,6 @@ async fn namespaces_plugin_skills_using_plugin_name() { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1522,8 +1152,6 @@ async fn loads_short_description_from_metadata() { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1636,8 +1264,6 @@ async fn loads_skills_from_repo_root() { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -1673,8 +1299,6 @@ async fn loads_skills_from_agents_dir_without_codex_dir() { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -1728,8 +1352,6 @@ async fn loads_skills_from_all_codex_dirs_under_project_root() { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(&nested_skill_path), scope: SkillScope::Repo, }, @@ -1740,8 +1362,6 @@ async fn loads_skills_from_all_codex_dirs_under_project_root() { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(&root_skill_path), scope: SkillScope::Repo, }, @@ -1781,8 +1401,6 @@ async fn loads_skills_from_codex_dir_when_not_git_repo() { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -1820,8 +1438,6 @@ async fn deduplicates_by_path_preferring_first_root() { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -1863,8 +1479,6 @@ async fn keeps_duplicate_names_from_repo_and_user() { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(&repo_skill_path), scope: SkillScope::Repo, }, @@ -1875,8 +1489,6 @@ async fn keeps_duplicate_names_from_repo_and_user() { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(&user_skill_path), scope: SkillScope::User, }, @@ -1940,8 +1552,6 @@ async fn keeps_duplicate_names_from_nested_codex_dirs() { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: first_path, scope: SkillScope::Repo, }, @@ -1952,8 +1562,6 @@ async fn keeps_duplicate_names_from_nested_codex_dirs() { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: second_path, scope: SkillScope::Repo, }, @@ -2025,8 +1633,6 @@ async fn loads_skills_when_cwd_is_file_in_repo() { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -2085,8 +1691,6 @@ async fn loads_skills_from_system_cache_when_present() { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::System, }] diff --git a/codex-rs/core-skills/src/manager_tests.rs b/codex-rs/core-skills/src/manager_tests.rs index 2507fc9823..62218f7311 100644 --- a/codex-rs/core-skills/src/manager_tests.rs +++ b/codex-rs/core-skills/src/manager_tests.rs @@ -57,8 +57,6 @@ fn test_skill(name: &str, path: PathBuf) -> SkillMetadata { interface: None, dependencies: None, policy: None, - permission_profile: None, - managed_network_override: None, path_to_skills_md: path, scope: SkillScope::User, } @@ -143,7 +141,10 @@ fn new_with_disabled_bundled_skills_removes_stale_cached_system_skills() { fs::write(stale_system_skill_dir.join("SKILL.md"), "# stale\n") .expect("write stale system skill"); - let _skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), false); + let _skills_manager = SkillsManager::new( + codex_home.path().to_path_buf(), + /*bundled_skills_enabled*/ false, + ); assert!( !codex_home.path().join("skills/.system").exists(), @@ -156,7 +157,10 @@ async fn skills_for_config_reuses_cache_for_same_effective_config() { let codex_home = tempfile::tempdir().expect("tempdir"); let cwd = tempfile::tempdir().expect("tempdir"); let config_layer_stack = config_stack(&codex_home, ""); - let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), true); + let skills_manager = SkillsManager::new( + codex_home.path().to_path_buf(), + /*bundled_skills_enabled*/ true, + ); write_user_skill(&codex_home, "a", "skill-a", "from a"); let outcome1 = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]); @@ -187,14 +191,17 @@ async fn skills_for_config_disables_plugin_skills_by_name() { ); let config_layer_stack = config_stack( &codex_home, - &name_toggle_config("sample:sample-search", false), + &name_toggle_config("sample:sample-search", /*enabled*/ false), ); let plugin_skill_root = skill_path .parent() .and_then(std::path::Path::parent) .expect("plugin skill should live under a skills root") .to_path_buf(); - let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), true); + let skills_manager = SkillsManager::new( + codex_home.path().to_path_buf(), + /*bundled_skills_enabled*/ true, + ); let outcome = skills_for_config_with_stack( &skills_manager, @@ -225,7 +232,10 @@ async fn skills_for_cwd_reuses_cached_entry_even_when_entry_has_extra_roots() { let cwd = tempfile::tempdir().expect("tempdir"); let extra_root = tempfile::tempdir().expect("tempdir"); let config_layer_stack = config_stack(&codex_home, ""); - let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), true); + let skills_manager = SkillsManager::new( + codex_home.path().to_path_buf(), + /*bundled_skills_enabled*/ true, + ); let _ = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]); write_user_skill(&extra_root, "x", "extra-skill", "from extra root"); @@ -239,7 +249,7 @@ async fn skills_for_cwd_reuses_cached_entry_even_when_entry_has_extra_roots() { let outcome_with_extra = skills_manager .skills_for_cwd_with_extra_user_roots( &base_input, - true, + /*force_reload*/ true, std::slice::from_ref(&extra_root_path), ) .await; @@ -264,7 +274,9 @@ async fn skills_for_cwd_reuses_cached_entry_even_when_entry_has_extra_roots() { config_layer_stack.clone(), bundled_skills_enabled_from_stack(&config_layer_stack), ); - let outcome_without_extra = skills_manager.skills_for_cwd(&base_input, false).await; + let outcome_without_extra = skills_manager + .skills_for_cwd(&base_input, /*force_reload*/ false) + .await; assert_eq!(outcome_without_extra.skills, outcome_with_extra.skills); assert_eq!(outcome_without_extra.errors, outcome_with_extra.errors); } @@ -281,7 +293,10 @@ async fn skills_for_config_excludes_bundled_skills_when_disabled_in_config() { ) .expect("write bundled skill"); let config_layer_stack = config_stack(&codex_home, "[skills.bundled]\nenabled = false\n"); - let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), false); + let skills_manager = SkillsManager::new( + codex_home.path().to_path_buf(), + /*bundled_skills_enabled*/ false, + ); // Recreate the cached bundled skill after startup cleanup so this assertion exercises // root selection rather than relying on directory removal succeeding. @@ -314,7 +329,10 @@ async fn skills_for_cwd_with_extra_roots_only_refreshes_on_force_reload() { let extra_root_a = tempfile::tempdir().expect("tempdir"); let extra_root_b = tempfile::tempdir().expect("tempdir"); let config_layer_stack = config_stack(&codex_home, ""); - let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), true); + let skills_manager = SkillsManager::new( + codex_home.path().to_path_buf(), + /*bundled_skills_enabled*/ true, + ); let _ = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]); write_user_skill(&extra_root_a, "x", "extra-skill-a", "from extra root a"); @@ -330,7 +348,7 @@ async fn skills_for_cwd_with_extra_roots_only_refreshes_on_force_reload() { let outcome_a = skills_manager .skills_for_cwd_with_extra_user_roots( &base_input, - true, + /*force_reload*/ true, std::slice::from_ref(&extra_root_a_path), ) .await; @@ -351,7 +369,7 @@ async fn skills_for_cwd_with_extra_roots_only_refreshes_on_force_reload() { let outcome_b = skills_manager .skills_for_cwd_with_extra_user_roots( &base_input, - false, + /*force_reload*/ false, std::slice::from_ref(&extra_root_b_path), ) .await; @@ -371,7 +389,7 @@ async fn skills_for_cwd_with_extra_roots_only_refreshes_on_force_reload() { let outcome_reloaded = skills_manager .skills_for_cwd_with_extra_user_roots( &base_input, - true, + /*force_reload*/ true, std::slice::from_ref(&extra_root_b_path), ) .await; @@ -410,11 +428,13 @@ fn disabled_paths_for_skills_allows_session_flags_to_override_user_layer() { .expect("user config path should be absolute"); let user_layer = ConfigLayerEntry::new( ConfigLayerSource::User { file: user_file }, - toml::from_str(&path_toggle_config(&skill_path, false)).expect("user layer toml"), + toml::from_str(&path_toggle_config(&skill_path, /*enabled*/ false)) + .expect("user layer toml"), ); let session_layer = ConfigLayerEntry::new( ConfigLayerSource::SessionFlags, - toml::from_str(&path_toggle_config(&skill_path, true)).expect("session layer toml"), + toml::from_str(&path_toggle_config(&skill_path, /*enabled*/ true)) + .expect("session layer toml"), ); let stack = ConfigLayerStack::new( vec![user_layer, session_layer], @@ -440,11 +460,13 @@ fn disabled_paths_for_skills_allows_session_flags_to_disable_user_enabled_skill( .expect("user config path should be absolute"); let user_layer = ConfigLayerEntry::new( ConfigLayerSource::User { file: user_file }, - toml::from_str(&path_toggle_config(&skill_path, true)).expect("user layer toml"), + toml::from_str(&path_toggle_config(&skill_path, /*enabled*/ true)) + .expect("user layer toml"), ); let session_layer = ConfigLayerEntry::new( ConfigLayerSource::SessionFlags, - toml::from_str(&path_toggle_config(&skill_path, false)).expect("session layer toml"), + toml::from_str(&path_toggle_config(&skill_path, /*enabled*/ false)) + .expect("session layer toml"), ); let stack = ConfigLayerStack::new( vec![user_layer, session_layer], @@ -470,7 +492,8 @@ fn disabled_paths_for_skills_disables_matching_name_selectors() { .expect("user config path should be absolute"); let user_layer = ConfigLayerEntry::new( ConfigLayerSource::User { file: user_file }, - toml::from_str(&name_toggle_config("github:yeet", false)).expect("user layer toml"), + toml::from_str(&name_toggle_config("github:yeet", /*enabled*/ false)) + .expect("user layer toml"), ); let stack = ConfigLayerStack::new( vec![user_layer], @@ -496,11 +519,13 @@ fn disabled_paths_for_skills_allows_name_selector_to_override_path_selector() { .expect("user config path should be absolute"); let user_layer = ConfigLayerEntry::new( ConfigLayerSource::User { file: user_file }, - toml::from_str(&path_toggle_config(&skill_path, false)).expect("user layer toml"), + toml::from_str(&path_toggle_config(&skill_path, /*enabled*/ false)) + .expect("user layer toml"), ); let session_layer = ConfigLayerEntry::new( ConfigLayerSource::SessionFlags, - toml::from_str(&name_toggle_config("github:yeet", true)).expect("session layer toml"), + toml::from_str(&name_toggle_config("github:yeet", /*enabled*/ true)) + .expect("session layer toml"), ); let stack = ConfigLayerStack::new( vec![user_layer, session_layer], @@ -529,12 +554,15 @@ async fn skills_for_config_ignores_cwd_cache_when_session_flags_reenable_skill() "---\nname: demo-skill\ndescription: demo description\n---\n\n# Body\n", ) .expect("write skill"); - let disabled_skill_config = path_toggle_config(&skill_path, false); - let enabled_skill_config = path_toggle_config(&skill_path, true); + let disabled_skill_config = path_toggle_config(&skill_path, /*enabled*/ false); + let enabled_skill_config = path_toggle_config(&skill_path, /*enabled*/ true); let parent_stack = config_stack(&codex_home, &disabled_skill_config); let child_stack = config_stack_with_session_flags(&codex_home, &disabled_skill_config, &enabled_skill_config); - let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), true); + let skills_manager = SkillsManager::new( + codex_home.path().to_path_buf(), + /*bundled_skills_enabled*/ true, + ); let parent_input = SkillsLoadInput::new( cwd.path().to_path_buf(), Vec::new(), @@ -542,7 +570,9 @@ async fn skills_for_config_ignores_cwd_cache_when_session_flags_reenable_skill() bundled_skills_enabled_from_stack(&parent_stack), ); - let parent_outcome = skills_manager.skills_for_cwd(&parent_input, true).await; + let parent_outcome = skills_manager + .skills_for_cwd(&parent_input, /*force_reload*/ true) + .await; let parent_skill = parent_outcome .skills .iter() diff --git a/codex-rs/core-skills/src/model.rs b/codex-rs/core-skills/src/model.rs index d47904b9c7..319ca4e64e 100644 --- a/codex-rs/core-skills/src/model.rs +++ b/codex-rs/core-skills/src/model.rs @@ -3,22 +3,8 @@ use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; -use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; -use serde::Deserialize; - -#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] -pub struct SkillManagedNetworkOverride { - pub allowed_domains: Option>, - pub denied_domains: Option>, -} - -impl SkillManagedNetworkOverride { - pub fn has_domain_overrides(&self) -> bool { - self.allowed_domains.is_some() || self.denied_domains.is_some() - } -} #[derive(Debug, Clone, PartialEq)] pub struct SkillMetadata { @@ -28,8 +14,6 @@ pub struct SkillMetadata { pub interface: Option, pub dependencies: Option, pub policy: Option, - pub permission_profile: Option, - pub managed_network_override: Option, /// Path to the SKILLS.md file that declares this skill. pub path_to_skills_md: PathBuf, pub scope: SkillScope, diff --git a/codex-rs/core/BUILD.bazel b/codex-rs/core/BUILD.bazel index e8773b42fd..9141579abc 100644 --- a/codex-rs/core/BUILD.bazel +++ b/codex-rs/core/BUILD.bazel @@ -1,17 +1,8 @@ load("//:defs.bzl", "codex_rust_crate") -exports_files( - [ - "templates/collaboration_mode/default.md", - "templates/collaboration_mode/plan.md", - ], - visibility = ["//visibility:public"], -) - filegroup( name = "model_availability_nux_fixtures", srcs = [ - "models.json", "tests/cli_responses_fixture.sse", ], visibility = ["//visibility:public"], @@ -32,16 +23,14 @@ codex_rust_crate( "//codex-rs:node-version.txt", ], rustc_env = { - # Askama resolves template paths relative to CARGO_MANIFEST_DIR. In - # Bazel, the Cargo-provided absolute source path points outside the - # sandbox, so keep the manifest root anchored inside the execroot. + # Keep manifest-root path lookups inside the Bazel execroot for code + # that relies on env!("CARGO_MANIFEST_DIR"). "CARGO_MANIFEST_DIR": "codex-rs/core", }, integration_compile_data_extra = [ "//codex-rs/apply-patch:apply_patch_tool_instructions.md", - "models.json", - "prompt.md", ], + integration_test_timeout = "long", test_data_extra = [ "config.schema.json", ] + glob([ @@ -58,6 +47,7 @@ codex_rust_crate( "//:AGENTS.md", ], test_tags = ["no-sandbox"], + unit_test_timeout = "long", extra_binaries = [ "//codex-rs/linux-sandbox:codex-linux-sandbox", "//codex-rs/rmcp-client:test_stdio_server", diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 25e44d7631..d2cbad83ef 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -18,13 +18,11 @@ workspace = true [dependencies] anyhow = { workspace = true } -arc-swap = "1.8.2" +arc-swap = { workspace = true } async-channel = { workspace = true } async-trait = { workspace = true } -askama = { workspace = true } base64 = { workspace = true } bm25 = { workspace = true } -chardetng = { workspace = true } chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["derive"] } codex-analytics = { workspace = true } @@ -38,7 +36,11 @@ codex-config = { workspace = true } codex-core-skills = { workspace = true } codex-exec-server = { workspace = true } codex-features = { workspace = true } +codex-feedback = { workspace = true } codex-login = { workspace = true } +codex-mcp = { workspace = true } +codex-model-provider-info = { workspace = true } +codex-models-manager = { workspace = true } codex-shell-command = { workspace = true } codex-execpolicy = { workspace = true } codex-git-utils = { workspace = true } @@ -46,14 +48,15 @@ codex-hooks = { workspace = true } codex-instructions = { workspace = true } codex-network-proxy = { workspace = true } codex-otel = { workspace = true } -codex-artifacts = { workspace = true } codex-plugin = { workspace = true } codex-protocol = { workspace = true } +codex-response-debug-context = { workspace = true } codex-rollout = { workspace = true } codex-rmcp-client = { workspace = true } codex-sandboxing = { workspace = true } codex-state = { workspace = true } codex-terminal-detection = { workspace = true } +codex-tools = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cache = { workspace = true } codex-utils-image = { workspace = true } @@ -66,11 +69,11 @@ codex-utils-readiness = { workspace = true } codex-secrets = { workspace = true } codex-utils-string = { workspace = true } codex-utils-stream-parser = { workspace = true } +codex-utils-template = { workspace = true } codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" } csv = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } -encoding_rs = { workspace = true } env-flags = { workspace = true } eventsource-stream = { workspace = true } futures = { workspace = true } @@ -90,7 +93,6 @@ rmcp = { workspace = true, default-features = false, features = [ "schemars", "server", ] } -schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha1 = { workspace = true } @@ -114,13 +116,8 @@ tracing = { workspace = true, features = ["log"] } url = { workspace = true } uuid = { workspace = true, features = ["serde", "v4", "v5"] } which = { workspace = true } -wildmatch = { workspace = true } zip = { workspace = true } -[target.'cfg(target_os = "linux")'.dependencies] -landlock = { workspace = true } -seccompiler = { workspace = true } - [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.9" @@ -146,10 +143,7 @@ codex-shell-escalation = { workspace = true } assert_cmd = { workspace = true } assert_matches = { workspace = true } codex-arg0 = { workspace = true } -codex-otel = { workspace = true, features = [ - "disable-default-metrics-exporter", -] } -codex-test-macros = { workspace = true } +codex-otel = { workspace = true } codex-utils-cargo-bin = { workspace = true } core_test_support = { workspace = true } ctor = { workspace = true } diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index c154ef463d..d8ea2ac984 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -17,32 +17,8 @@ pointer file), the resolved `gitdir:` target, and `.codex` read-only. Network access and filesystem read/write roots are controlled by `SandboxPolicy`. Seatbelt consumes the resolved policy and enforces it. -Seatbelt also supports macOS permission-profile extensions layered on top of -`SandboxPolicy`: - -- no extension profile provided: - keeps legacy default preferences read access (`user-preference-read`). -- extension profile provided with no `macos_preferences` grant: - does not add preferences access clauses. -- `macos_preferences = "readonly"`: - enables cfprefs read clauses and `user-preference-read`. -- `macos_preferences = "readwrite"`: - includes readonly clauses plus `user-preference-write` and cfprefs shm write - clauses. -- `macos_automation = true`: - enables broad Apple Events send permissions. -- `macos_automation = ["com.apple.Notes", ...]`: - enables Apple Events send only to listed bundle IDs. -- `macos_launch_services = true`: - enables LaunchServices lookups and open/launch operations. -- `macos_accessibility = true`: - enables `com.apple.axserver` mach lookup. -- `macos_calendar = true`: - enables `com.apple.CalendarAgent` mach lookup. -- `macos_contacts = "read_only"`: - enables Address Book read access and Contacts read services. -- `macos_contacts = "read_write"`: - includes the readonly Contacts clauses plus Address Book writes and keychain/temp helpers required for writes. +Seatbelt also keeps the legacy default preferences read access +(`user-preference-read`) needed for cfprefs-backed macOS behavior. ### Linux @@ -59,12 +35,14 @@ only when the split filesystem policy round-trips through the legacy cases like `/repo = write`, `/repo/a = none`, `/repo/a/b = write`, where the more specific writable child must reopen under a denied parent. -The Linux sandbox helper prefers `/usr/bin/bwrap` whenever it is available and -supports the required argv-rewrite flags, and falls back to the vendored -bubblewrap path compiled into the binary otherwise. When `/usr/bin/bwrap` is -missing or too old to support the required flags, Codex also surfaces a startup -warning through its normal notification path instead of printing directly from -the sandbox helper. +The Linux sandbox helper prefers the first `bwrap` found on `PATH` outside the +current working directory whenever it is available. If `bwrap` is present but +too old to support `--argv0`, the helper keeps using system bubblewrap and +switches to a no-`--argv0` compatibility path for the inner re-exec. If +`bwrap` is missing, it falls back to the vendored bubblewrap path compiled into +the binary and Codex surfaces a startup warning through its normal notification +path instead of printing directly from the sandbox helper. Codex also surfaces +a startup warning when bubblewrap cannot create user namespaces. ### Windows diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 3e2d7ff689..e4e7ff85b7 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -338,9 +338,6 @@ "apps": { "type": "boolean" }, - "artifact": { - "type": "boolean" - }, "child_agents_md": { "type": "boolean" }, @@ -365,6 +362,9 @@ "connectors": { "type": "boolean" }, + "debug_hide_spawn_agent_metadata": { + "type": "boolean" + }, "default_mode_request_user_input": { "type": "boolean" }, @@ -395,6 +395,9 @@ "fast_mode": { "type": "boolean" }, + "general_analytics": { + "type": "boolean" + }, "guardian_approval": { "type": "boolean" }, @@ -437,6 +440,9 @@ "realtime_conversation": { "type": "boolean" }, + "remote_control": { + "type": "boolean" + }, "remote_models": { "type": "boolean" }, @@ -506,9 +512,6 @@ "use_linux_sandbox_bwrap": { "type": "boolean" }, - "voice_transcription": { - "type": "boolean" - }, "web_search": { "type": "boolean" }, @@ -524,6 +527,15 @@ "include_apply_patch_tool": { "type": "boolean" }, + "include_apps_instructions": { + "type": "boolean" + }, + "include_environment_context": { + "type": "boolean" + }, + "include_permissions_instructions": { + "type": "boolean" + }, "js_repl_node_module_dirs": { "description": "Ordered list of directories to search for Node modules in `js_repl`.", "items": { @@ -745,6 +757,21 @@ } ] }, + "McpServerToolConfig": { + "additionalProperties": false, + "description": "Per-tool approval settings for a single MCP server tool.", + "properties": { + "approval_mode": { + "allOf": [ + { + "$ref": "#/definitions/AppToolApproval" + } + ], + "description": "Approval mode for this tool." + } + }, + "type": "object" + }, "MemoriesToml": { "additionalProperties": false, "description": "Memories settings loaded from config.toml.", @@ -807,10 +834,62 @@ }, "type": "object" }, + "ModelProviderAuthInfo": { + "additionalProperties": false, + "description": "Configuration for obtaining a provider bearer token from a command.", + "properties": { + "args": { + "default": [], + "description": "Command arguments.", + "items": { + "type": "string" + }, + "type": "array" + }, + "command": { + "description": "Command to execute. Bare names are resolved via `PATH`; paths are resolved against `cwd`.", + "type": "string" + }, + "cwd": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Working directory used when running the token command." + }, + "refresh_interval_ms": { + "default": 300000, + "description": "Maximum age for the cached token before rerunning the command. Set to `0` to disable proactive refresh and only rerun after a 401 retry path.", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "timeout_ms": { + "default": 5000, + "description": "Maximum time to wait for the token command to exit successfully.", + "format": "uint64", + "minimum": 1.0, + "type": "integer" + } + }, + "required": [ + "command" + ], + "type": "object" + }, "ModelProviderInfo": { "additionalProperties": false, "description": "Serializable representation of a provider definition.", "properties": { + "auth": { + "allOf": [ + { + "$ref": "#/definitions/ModelProviderAuthInfo" + } + ], + "description": "Command-backed bearer-token configuration for this provider." + }, "base_url": { "description": "Base URL for the provider's OpenAI-compatible API.", "type": "string" @@ -901,6 +980,16 @@ ], "type": "object" }, + "NetworkDomainPermissionToml": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, + "NetworkDomainPermissionsToml": { + "type": "object" + }, "NetworkModeSchema": { "enum": [ "limited", @@ -914,32 +1003,17 @@ "allow_local_binding": { "type": "boolean" }, - "allow_unix_sockets": { - "items": { - "type": "string" - }, - "type": "array" - }, "allow_upstream_proxy": { "type": "boolean" }, - "allowed_domains": { - "items": { - "type": "string" - }, - "type": "array" - }, "dangerously_allow_all_unix_sockets": { "type": "boolean" }, "dangerously_allow_non_loopback_proxy": { "type": "boolean" }, - "denied_domains": { - "items": { - "type": "string" - }, - "type": "array" + "domains": { + "$ref": "#/definitions/NetworkDomainPermissionsToml" }, "enable_socks5": { "type": "boolean" @@ -958,10 +1032,23 @@ }, "socks_url": { "type": "string" + }, + "unix_sockets": { + "$ref": "#/definitions/NetworkUnixSocketPermissionsToml" } }, "type": "object" }, + "NetworkUnixSocketPermissionToml": { + "enum": [ + "allow", + "none" + ], + "type": "string" + }, + "NetworkUnixSocketPermissionsToml": { + "type": "object" + }, "Notice": { "description": "Settings for notices we display to users via the tui and app-server clients (primarily the Codex IDE extension). NOTE: these are different from notifications - notices are warnings, NUX screens, acknowledgements, etc.", "properties": { @@ -1247,6 +1334,7 @@ }, "RawMcpServerConfig": { "additionalProperties": false, + "description": "Raw MCP config shape used for deserialization and JSON Schema generation.\n\nKeep `TryFrom for McpServerConfig` exhaustively destructuring this struct so new TOML fields cannot be added here without updating the validation/mapping logic that produces [`McpServerConfig`].", "properties": { "args": { "default": null, @@ -1313,6 +1401,11 @@ }, "type": "object" }, + "name": { + "default": null, + "description": "Legacy display-name field accepted for backward compatibility.", + "type": "string" + }, "oauth_resource": { "default": null, "type": "string" @@ -1344,6 +1437,13 @@ "format": "double", "type": "number" }, + "tools": { + "additionalProperties": { + "$ref": "#/definitions/McpServerToolConfig" + }, + "default": null, + "type": "object" + }, "url": { "type": "string" } @@ -1914,10 +2014,6 @@ "experimental_compact_prompt_file": { "$ref": "#/definitions/AbsolutePathBuf" }, - "experimental_exec_server_url": { - "description": "Experimental / do not use. Overrides the URL used when connecting to a remote exec server.", - "type": "string" - }, "experimental_realtime_start_instructions": { "description": "Experimental / do not use. Replaces the built-in realtime start instructions inserted into developer messages when realtime becomes active.", "type": "string" @@ -1955,9 +2051,6 @@ "apps": { "type": "boolean" }, - "artifact": { - "type": "boolean" - }, "child_agents_md": { "type": "boolean" }, @@ -1982,6 +2075,9 @@ "connectors": { "type": "boolean" }, + "debug_hide_spawn_agent_metadata": { + "type": "boolean" + }, "default_mode_request_user_input": { "type": "boolean" }, @@ -2012,6 +2108,9 @@ "fast_mode": { "type": "boolean" }, + "general_analytics": { + "type": "boolean" + }, "guardian_approval": { "type": "boolean" }, @@ -2054,6 +2153,9 @@ "realtime_conversation": { "type": "boolean" }, + "remote_control": { + "type": "boolean" + }, "remote_models": { "type": "boolean" }, @@ -2123,9 +2225,6 @@ "use_linux_sandbox_bwrap": { "type": "boolean" }, - "voice_transcription": { - "type": "boolean" - }, "web_search": { "type": "boolean" }, @@ -2190,6 +2289,18 @@ "default": null, "description": "Settings that govern if and what will be written to `~/.codex/history.jsonl`." }, + "include_apps_instructions": { + "description": "Whether to inject the `` developer block.", + "type": "boolean" + }, + "include_environment_context": { + "description": "Whether to inject the `` user block.", + "type": "boolean" + }, + "include_permissions_instructions": { + "description": "Whether to inject the `` developer block.", + "type": "boolean" + }, "instructions": { "description": "System instructions.", "type": "string" @@ -2318,7 +2429,7 @@ "$ref": "#/definitions/Notice" } ], - "description": "Collection of in-product notices (different from notifications) See [`crate::config::types::Notices`] for more details" + "description": "Collection of in-product notices (different from notifications) See [`crate::types::Notice`] for more details" }, "notify": { "default": null, diff --git a/codex-rs/core/src/agent/agent_resolver.rs b/codex-rs/core/src/agent/agent_resolver.rs index 3d1f75f57f..adb3f23ac6 100644 --- a/codex-rs/core/src/agent/agent_resolver.rs +++ b/codex-rs/core/src/agent/agent_resolver.rs @@ -21,32 +21,13 @@ pub(crate) async fn resolve_agent_target( .resolve_agent_reference(session.conversation_id, &turn.session_source, target) .await .map_err(|err| match err { - crate::error::CodexErr::UnsupportedOperation(message) => { + codex_protocol::error::CodexErr::UnsupportedOperation(message) => { FunctionCallError::RespondToModel(message) } other => FunctionCallError::RespondToModel(other.to_string()), }) } -/// Resolves multiple tool-facing agent targets to thread ids. -pub(crate) async fn resolve_agent_targets( - session: &Arc, - turn: &Arc, - targets: Vec, -) -> Result, FunctionCallError> { - if targets.is_empty() { - return Err(FunctionCallError::RespondToModel( - "agent targets must be non-empty".to_string(), - )); - } - - let mut resolved = Vec::with_capacity(targets.len()); - for target in &targets { - resolved.push(resolve_agent_target(session, turn, target).await?); - } - Ok(resolved) -} - fn register_session_root(session: &Arc, turn: &Arc) { session .services diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index a3b9897688..529615a7a5 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -4,26 +4,22 @@ use crate::agent::registry::AgentRegistry; use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::resolve_role_config; use crate::agent::status::is_final; +use crate::codex::emit_subagent_session_started; use crate::codex_thread::ThreadConfigSnapshot; -use crate::context_manager::is_user_turn_boundary; -use crate::error::CodexErr; -use crate::error::Result as CodexResult; -use crate::event_mapping::parse_turn_item; use crate::find_archived_thread_path_by_id_str; use crate::find_thread_path_by_id_str; use crate::rollout::RolloutRecorder; use crate::session_prefix::format_subagent_context_line; use crate::session_prefix::format_subagent_notification_message; use crate::shell_snapshot::ShellSnapshot; -use crate::state_db; use crate::thread_manager::ThreadManagerState; +use crate::thread_rollout_truncation::truncate_rollout_to_last_n_fork_turns; use codex_features::Feature; use codex_protocol::AgentPath; use codex_protocol::ThreadId; -use codex_protocol::items::TurnItem; -use codex_protocol::models::ContentItem; -use codex_protocol::models::FunctionCallOutputPayload; -use codex_protocol::models::ResponseInputItem; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result as CodexResult; +use codex_protocol::models::MessagePhase; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; @@ -33,6 +29,7 @@ use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; use codex_protocol::user_input::UserInput; +use codex_rollout::state_db; use codex_state::DirectionalThreadSpawnEdgeStatus; use serde::Serialize; use std::collections::HashMap; @@ -43,11 +40,18 @@ use tokio::sync::watch; use tracing::warn; const AGENT_NAMES: &str = include_str!("agent_names.txt"); -const FORKED_SPAWN_AGENT_OUTPUT_MESSAGE: &str = "You are the newly spawned agent. The prior conversation history was forked from your parent agent. Treat the next user message as your new task, and use the forked history only as background context."; +const ROOT_LAST_TASK_MESSAGE: &str = "Main thread"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SpawnAgentForkMode { + FullHistory, + LastNTurns(usize), +} #[derive(Clone, Debug, Default)] pub(crate) struct SpawnAgentOptions { pub(crate) fork_parent_spawn_call_id: Option, + pub(crate) fork_mode: Option, } #[derive(Clone, Debug)] @@ -89,6 +93,36 @@ fn agent_nickname_candidates( .collect() } +fn keep_forked_rollout_item(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(ResponseItem::Message { role, phase, .. }) => match role.as_str() + { + "system" | "developer" | "user" => true, + "assistant" => *phase == Some(MessagePhase::FinalAnswer), + _ => false, + }, + RolloutItem::ResponseItem( + ResponseItem::Reasoning { .. } + | ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } + | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } + | ResponseItem::WebSearchCall { .. } + | ResponseItem::ImageGenerationCall { .. } + | ResponseItem::GhostSnapshot { .. } + | ResponseItem::Compaction { .. } + | ResponseItem::Other, + ) => false, + RolloutItem::Compacted(_) + | RolloutItem::EventMsg(_) + | RolloutItem::SessionMeta(_) + | RolloutItem::TurnContext(_) => true, + } +} + /// Control-plane handle for multi-agent operations. /// `AgentControl` is held by each session (via `SessionServices`). It provides capability to /// spawn new agents and the inter-agent communication layer. @@ -117,30 +151,36 @@ impl AgentControl { pub(crate) async fn spawn_agent( &self, config: crate::config::Config, - items: Vec, + initial_operation: Op, session_source: Option, ) -> CodexResult { Ok(self - .spawn_agent_internal(config, items, session_source, SpawnAgentOptions::default()) + .spawn_agent_internal( + config, + initial_operation, + session_source, + SpawnAgentOptions::default(), + ) .await? .thread_id) } + /// Spawn an agent thread with some metadata. pub(crate) async fn spawn_agent_with_metadata( &self, config: crate::config::Config, - items: Vec, + initial_operation: Op, session_source: Option, - options: SpawnAgentOptions, + options: SpawnAgentOptions, // TODO(jif) drop with new fork. ) -> CodexResult { - self.spawn_agent_internal(config, items, session_source, options) + self.spawn_agent_internal(config, initial_operation, session_source, options) .await } async fn spawn_agent_internal( &self, config: crate::config::Config, - items: Vec, + initial_operation: Op, session_source: Option, options: SpawnAgentOptions, ) -> CodexResult { @@ -176,87 +216,78 @@ impl AgentControl { let notification_source = session_source.clone(); // The same `AgentControl` is sent to spawn the thread. - let new_thread = match session_source { - Some(session_source) => { - if let Some(call_id) = options.fork_parent_spawn_call_id.as_ref() { - let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - .. - }) = session_source.clone() - else { - return Err(CodexErr::Fatal( - "spawn_agent fork requires a thread-spawn session source".to_string(), - )); - }; - let parent_thread = state.get_thread(parent_thread_id).await.ok(); - if let Some(parent_thread) = parent_thread.as_ref() { - // `record_conversation_items` only queues rollout writes asynchronously. - // Flush/materialize the live parent before snapshotting JSONL for a fork. - parent_thread - .codex - .session - .ensure_rollout_materialized() - .await; - parent_thread.codex.session.flush_rollout().await; - } - let rollout_path = parent_thread - .as_ref() - .and_then(|parent_thread| parent_thread.rollout_path()) - .or(find_thread_path_by_id_str( - config.codex_home.as_path(), - &parent_thread_id.to_string(), - ) - .await?) - .ok_or_else(|| { - CodexErr::Fatal(format!( - "parent thread rollout unavailable for fork: {parent_thread_id}" - )) - })?; - let mut forked_rollout_items: Vec = - RolloutRecorder::get_rollout_history(&rollout_path) - .await? - .get_rollout_items(); - let mut output = FunctionCallOutputPayload::from_text( - FORKED_SPAWN_AGENT_OUTPUT_MESSAGE.to_string(), - ); - output.success = Some(true); - forked_rollout_items.push(RolloutItem::ResponseItem( - ResponseItem::FunctionCallOutput { - call_id: call_id.clone(), - output, - }, - )); - let initial_history = InitialHistory::Forked(forked_rollout_items); - state - .fork_thread_with_source( - config, - initial_history, - self.clone(), - session_source, - /*persist_extended_history*/ false, - inherited_shell_snapshot, - inherited_exec_policy, - ) - .await? - } else { - state - .spawn_new_thread_with_source( - config, - self.clone(), - session_source, - /*persist_extended_history*/ false, - /*metrics_service_name*/ None, - inherited_shell_snapshot, - inherited_exec_policy, - ) - .await? - } + let new_thread = match (session_source, options.fork_mode.as_ref()) { + (Some(session_source), Some(_)) => { + self.spawn_forked_thread( + &state, + config, + session_source, + &options, + inherited_shell_snapshot, + inherited_exec_policy, + ) + .await? } - None => state.spawn_new_thread(config, self.clone()).await?, + (Some(session_source), None) => { + state + .spawn_new_thread_with_source( + config, + self.clone(), + session_source, + /*persist_extended_history*/ false, + /*metrics_service_name*/ None, + inherited_shell_snapshot, + inherited_exec_policy, + ) + .await? + } + (None, _) => state.spawn_new_thread(config, self.clone()).await?, }; agent_metadata.agent_id = Some(new_thread.thread_id); reservation.commit(agent_metadata.clone()); + if let Some(SessionSource::SubAgent( + subagent_source @ SubAgentSource::ThreadSpawn { + parent_thread_id, .. + }, + )) = notification_source.as_ref() + && new_thread.thread.enabled(Feature::GeneralAnalytics) + { + let client_metadata = match state.get_thread(*parent_thread_id).await { + Ok(parent_thread) => { + parent_thread + .codex + .session + .app_server_client_metadata() + .await + } + Err(error) => { + tracing::warn!( + error = %error, + parent_thread_id = %parent_thread_id, + "skipping subagent thread analytics: failed to load parent thread metadata" + ); + crate::codex::AppServerClientMetadata { + client_name: None, + client_version: None, + } + } + }; + let thread_config = new_thread.thread.codex.thread_config_snapshot().await; + emit_subagent_session_started( + &new_thread + .thread + .codex + .session + .services + .analytics_events_client, + client_metadata, + new_thread.thread_id, + thread_config, + subagent_source.clone(), + ); + } + // Notify a new thread has been created. This notification will be processed by clients // to subscribe or drain this newly created thread. // TODO(jif) add helper for drain @@ -269,18 +300,21 @@ impl AgentControl { ) .await; - self.send_input(new_thread.thread_id, items).await?; - let child_reference = agent_metadata - .agent_path - .as_ref() - .map(ToString::to_string) - .unwrap_or_else(|| new_thread.thread_id.to_string()); - self.maybe_start_completion_watcher( - new_thread.thread_id, - notification_source, - child_reference, - agent_metadata.agent_path.clone(), - ); + self.send_input(new_thread.thread_id, initial_operation) + .await?; + if !new_thread.thread.enabled(Feature::MultiAgentV2) { + let child_reference = agent_metadata + .agent_path + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| new_thread.thread_id.to_string()); + self.maybe_start_completion_watcher( + new_thread.thread_id, + notification_source, + child_reference, + agent_metadata.agent_path.clone(), + ); + } Ok(LiveAgent { thread_id: new_thread.thread_id, @@ -289,6 +323,83 @@ impl AgentControl { }) } + async fn spawn_forked_thread( + &self, + state: &Arc, + config: crate::config::Config, + session_source: SessionSource, + options: &SpawnAgentOptions, + inherited_shell_snapshot: Option>, + inherited_exec_policy: Option>, + ) -> CodexResult { + if options.fork_parent_spawn_call_id.is_none() { + return Err(CodexErr::Fatal( + "spawn_agent fork requires a parent spawn call id".to_string(), + )); + } + let Some(fork_mode) = options.fork_mode.as_ref() else { + return Err(CodexErr::Fatal( + "spawn_agent fork requires a fork mode".to_string(), + )); + }; + let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, .. + }) = &session_source + else { + return Err(CodexErr::Fatal( + "spawn_agent fork requires a thread-spawn session source".to_string(), + )); + }; + + let parent_thread_id = *parent_thread_id; + let parent_thread = state.get_thread(parent_thread_id).await.ok(); + if let Some(parent_thread) = parent_thread.as_ref() { + // `record_conversation_items` only queues rollout writes asynchronously. + // Flush/materialize the live parent before snapshotting JSONL for a fork. + parent_thread + .codex + .session + .ensure_rollout_materialized() + .await; + parent_thread.codex.session.flush_rollout().await; + } + + let rollout_path = parent_thread + .as_ref() + .and_then(|parent_thread| parent_thread.rollout_path()) + .or(find_thread_path_by_id_str( + config.codex_home.as_path(), + &parent_thread_id.to_string(), + ) + .await?) + .ok_or_else(|| { + CodexErr::Fatal(format!( + "parent thread rollout unavailable for fork: {parent_thread_id}" + )) + })?; + + let mut forked_rollout_items = RolloutRecorder::get_rollout_history(&rollout_path) + .await? + .get_rollout_items(); + if let SpawnAgentForkMode::LastNTurns(last_n_turns) = fork_mode { + forked_rollout_items = + truncate_rollout_to_last_n_fork_turns(&forked_rollout_items, *last_n_turns); + } + forked_rollout_items.retain(keep_forked_rollout_item); + + state + .fork_thread_with_source( + config, + InitialHistory::Forked(forked_rollout_items), + self.clone(), + session_source, + /*persist_extended_history*/ false, + inherited_shell_snapshot, + inherited_exec_policy, + ) + .await + } + /// Resume an existing agent thread from a recorded rollout file. pub(crate) async fn resume_agent_from_rollout( &self, @@ -442,17 +553,19 @@ impl AgentControl { // Resumed threads are re-registered in-memory and need the same listener // attachment path as freshly spawned threads. state.notify_thread_created(resumed_thread.thread_id); - let child_reference = agent_metadata - .agent_path - .as_ref() - .map(ToString::to_string) - .unwrap_or_else(|| resumed_thread.thread_id.to_string()); - self.maybe_start_completion_watcher( - resumed_thread.thread_id, - Some(notification_source.clone()), - child_reference, - agent_metadata.agent_path.clone(), - ); + if !resumed_thread.thread.enabled(Feature::MultiAgentV2) { + let child_reference = agent_metadata + .agent_path + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| resumed_thread.thread_id.to_string()); + self.maybe_start_completion_watcher( + resumed_thread.thread_id, + Some(notification_source.clone()), + child_reference, + agent_metadata.agent_path.clone(), + ); + } self.persist_thread_spawn_edge_for_source( resumed_thread.thread.as_ref(), resumed_thread.thread_id, @@ -467,23 +580,15 @@ impl AgentControl { pub(crate) async fn send_input( &self, agent_id: ThreadId, - items: Vec, + initial_operation: Op, ) -> CodexResult { - let last_task_message = render_input_preview(&items); + let last_task_message = render_input_preview(&initial_operation); let state = self.upgrade()?; let result = self .handle_thread_request_result( agent_id, &state, - state - .send_op( - agent_id, - Op::UserInput { - items, - final_output_json_schema: None, - }, - ) - .await, + state.send_op(agent_id, initial_operation).await, ) .await; if result.is_ok() { @@ -625,6 +730,15 @@ impl AgentControl { self.state.agent_metadata_for_thread(agent_id) } + pub(crate) async fn list_live_agent_subtree_thread_ids( + &self, + agent_id: ThreadId, + ) -> CodexResult> { + let mut thread_ids = vec![agent_id]; + thread_ids.extend(self.live_thread_spawn_descendants(agent_id).await?); + Ok(thread_ids) + } + pub(crate) async fn get_agent_config_snapshot( &self, agent_id: ThreadId, @@ -650,12 +764,6 @@ impl AgentControl { let agent_path = current_agent_path .resolve(agent_reference) .map_err(CodexErr::UnsupportedOperation)?; - if agent_path.is_root() { - return Err(CodexErr::UnsupportedOperation( - "root is not a spawned agent".to_string(), - )); - } - if let Some(thread_id) = self.state.agent_id_for_path(&agent_path) { return Ok(thread_id); } @@ -737,7 +845,21 @@ impl AgentControl { }) }); - let mut agents = Vec::with_capacity(live_agents.len()); + let root_path = AgentPath::root(); + let mut agents = Vec::with_capacity(live_agents.len().saturating_add(1)); + if resolved_prefix + .as_ref() + .is_none_or(|prefix| agent_matches_prefix(Some(&root_path), prefix)) + && let Some(root_thread_id) = self.state.agent_id_for_path(&root_path) + && let Ok(root_thread) = state.get_thread(root_thread_id).await + { + agents.push(ListedAgent { + agent_name: root_path.to_string(), + agent_status: root_thread.agent_status().await, + last_task_message: Some(ROOT_LAST_TASK_MESSAGE.to_string()), + }); + } + for metadata in live_agents { let Some(thread_id) = metadata.agent_id else { continue; @@ -757,10 +879,7 @@ impl AgentControl { .as_ref() .map(ToString::to_string) .unwrap_or_else(|| thread_id.to_string()); - let last_task_message = match metadata.last_task_message.clone() { - Some(last_task_message) => Some(last_task_message), - None => last_task_message_for_thread(thread.as_ref()).await, - }; + let last_task_message = metadata.last_task_message.clone(); agents.push(ListedAgent { agent_name, agent_status: thread.agent_status().await, @@ -1063,79 +1182,23 @@ fn agent_matches_prefix(agent_path: Option<&AgentPath>, prefix: &AgentPath) -> b }) } -async fn last_task_message_for_thread(thread: &crate::CodexThread) -> Option { - let pending_input = thread.codex.session.pending_input_snapshot().await; - if let Some(message) = pending_input - .iter() - .rev() - .find_map(last_task_message_from_input_item) - { - return Some(message); +pub(crate) fn render_input_preview(initial_operation: &Op) -> String { + match initial_operation { + Op::UserInput { items, .. } => items + .iter() + .map(|item| match item { + UserInput::Text { text, .. } => text.clone(), + UserInput::Image { .. } => "[image]".to_string(), + UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()), + UserInput::Skill { name, path } => format!("[skill:${name}]({})", path.display()), + UserInput::Mention { name, path } => format!("[mention:${name}]({path})"), + _ => "[input]".to_string(), + }) + .collect::>() + .join("\n"), + Op::InterAgentCommunication { communication } => communication.content.clone(), + _ => String::new(), } - - let queued_input = thread - .codex - .session - .queued_response_items_for_next_turn_snapshot() - .await; - if let Some(message) = queued_input - .iter() - .rev() - .find_map(last_task_message_from_input_item) - { - return Some(message); - } - - let history = thread.codex.session.clone_history().await; - history - .raw_items() - .iter() - .rev() - .find_map(last_task_message_from_item) -} - -fn last_task_message_from_input_item(item: &ResponseInputItem) -> Option { - let response_item: ResponseItem = item.clone().into(); - last_task_message_from_item(&response_item) -} - -fn last_task_message_from_item(item: &ResponseItem) -> Option { - if !is_user_turn_boundary(item) { - return None; - } - - match item { - ResponseItem::Message { role, .. } if role == "user" => { - let Some(TurnItem::UserMessage(message)) = parse_turn_item(item) else { - return None; - }; - Some(render_input_preview(&message.content)) - } - ResponseItem::Message { content, .. } => match content.as_slice() { - [ContentItem::InputText { text }] | [ContentItem::OutputText { text }] => { - serde_json::from_str::(text) - .ok() - .map(|communication| communication.content) - } - _ => None, - }, - _ => None, - } -} - -fn render_input_preview(items: &[UserInput]) -> String { - items - .iter() - .map(|item| match item { - UserInput::Text { text, .. } => text.clone(), - UserInput::Image { .. } => "[image]".to_string(), - UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()), - UserInput::Skill { name, path } => format!("[skill:${name}]({})", path.display()), - UserInput::Mention { name, path } => format!("[mention:${name}]({path})"), - _ => "[input]".to_string(), - }) - .collect::>() - .join("\n") } fn thread_spawn_depth(session_source: &SessionSource) -> Option { diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 10e9821aea..54693228fa 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -1,19 +1,19 @@ use super::*; -use crate::CodexAuth; use crate::CodexThread; use crate::ThreadManager; use crate::agent::agent_status_from_event; use crate::config::AgentRoleConfig; use crate::config::Config; use crate::config::ConfigBuilder; -use crate::config_loader::LoaderOverrides; use crate::contextual_user_message::SUBAGENT_NOTIFICATION_OPEN_TAG; use assert_matches::assert_matches; use chrono::Utc; use codex_features::Feature; +use codex_login::CodexAuth; use codex_protocol::AgentPath; use codex_protocol::config_types::ModeKind; use codex_protocol::models::ContentItem; +use codex_protocol::models::MessagePhase; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::EventMsg; @@ -35,15 +35,9 @@ async fn test_config_with_cli_overrides( cli_overrides: Vec<(String, TomlValue)>, ) -> (TempDir, Config) { let home = TempDir::new().expect("create temp dir"); - let config = ConfigBuilder::default() + let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(home.path().to_path_buf()) .cli_overrides(cli_overrides) - .loader_overrides(LoaderOverrides { - #[cfg(target_os = "macos")] - managed_preferences_base64: Some(String::new()), - macos_managed_config_requirements_base64: Some(String::new()), - ..LoaderOverrides::default() - }) .build() .await .expect("load default test config"); @@ -54,11 +48,34 @@ async fn test_config() -> (TempDir, Config) { test_config_with_cli_overrides(Vec::new()).await } -fn text_input(text: &str) -> Vec { +fn text_input(text: &str) -> Op { vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), }] + .into() +} + +fn assistant_message(text: &str, phase: Option) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: text.to_string(), + }], + end_turn: None, + phase, + } +} + +fn spawn_agent_call(call_id: &str) -> ResponseItem { + ResponseItem::FunctionCall { + id: None, + name: "spawn_agent".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: call_id.to_string(), + } } struct AgentControlHarness { @@ -75,6 +92,9 @@ impl AgentControlHarness { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.clone(), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( + /*exec_server_url*/ None, + )), ); let control = manager.agent_control(); Self { @@ -214,7 +234,8 @@ async fn send_input_errors_when_manager_dropped() { vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), - }], + }] + .into(), ) .await .expect_err("send_input should fail without a manager"); @@ -235,6 +256,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, })); @@ -246,6 +268,8 @@ async fn on_event_updates_status_from_task_complete() { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -267,6 +291,8 @@ async fn on_event_updates_status_from_turn_aborted() { let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, })); let expected = AgentStatus::Interrupted; @@ -284,7 +310,7 @@ async fn spawn_agent_errors_when_manager_dropped() { let control = AgentControl::default(); let (_home, config) = test_config().await; let err = control - .spawn_agent(config, text_input("hello"), None) + .spawn_agent(config, text_input("hello"), /*session_source*/ None) .await .expect_err("spawn_agent should fail without a manager"); assert_eq!( @@ -318,7 +344,8 @@ async fn send_input_errors_when_thread_missing() { vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), - }], + }] + .into(), ) .await .expect_err("send_input should fail for missing thread"); @@ -384,7 +411,8 @@ async fn send_input_submits_user_message() { vec![UserInput::Text { text: "hello from tests".to_string(), text_elements: Vec::new(), - }], + }] + .into(), ) .await .expect("send_input should succeed"); @@ -416,7 +444,7 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig AgentPath::try_from("/root/worker").expect("agent path"), Vec::new(), "hello from tests".to_string(), - false, + /*trigger_turn*/ false, ); let submission_id = harness @@ -441,12 +469,7 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig timeout(Duration::from_secs(5), async { loop { - if thread - .codex - .session - .has_queued_response_items_for_next_turn() - .await - { + if thread.codex.session.has_pending_input().await { break; } sleep(Duration::from_millis(10)).await; @@ -528,7 +551,11 @@ async fn spawn_agent_creates_thread_and_sends_prompt() { let harness = AgentControlHarness::new().await; let thread_id = harness .control - .spawn_agent(harness.config.clone(), text_input("spawned"), None) + .spawn_agent( + harness.config.clone(), + text_input("spawned"), + /*session_source*/ None, + ) .await .expect("spawn_agent should succeed"); let _thread = harness @@ -555,7 +582,7 @@ async fn spawn_agent_creates_thread_and_sends_prompt() { } #[tokio::test] -async fn spawn_agent_can_fork_parent_thread_history() { +async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { let harness = AgentControlHarness::new().await; let (parent_thread_id, parent_thread) = harness.start_thread().await; parent_thread @@ -563,17 +590,32 @@ async fn spawn_agent_can_fork_parent_thread_history() { .await; let turn_context = parent_thread.codex.session.new_default_turn().await; let parent_spawn_call_id = "spawn-call-history".to_string(); - let parent_spawn_call = ResponseItem::FunctionCall { - id: None, - name: "spawn_agent".to_string(), - namespace: None, - arguments: "{}".to_string(), - call_id: parent_spawn_call_id.clone(), - }; + let trigger_message = InterAgentCommunication::new( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + Vec::new(), + "parent trigger message".to_string(), + /*trigger_turn*/ true, + ); parent_thread .codex .session - .record_conversation_items(turn_context.as_ref(), &[parent_spawn_call]) + .record_conversation_items( + turn_context.as_ref(), + &[ + assistant_message("parent commentary", Some(MessagePhase::Commentary)), + assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)), + assistant_message("parent unknown phase", /*phase*/ None), + ResponseItem::Reasoning { + id: "parent-reasoning".to_string(), + summary: Vec::new(), + content: None, + encrypted_content: None, + }, + trigger_message.to_response_input_item().into(), + spawn_agent_call(&parent_spawn_call_id), + ], + ) .await; parent_thread .codex @@ -595,7 +637,8 @@ async fn spawn_agent_can_fork_parent_thread_history() { agent_role: None, })), SpawnAgentOptions { - fork_parent_spawn_call_id: Some(parent_spawn_call_id), + fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()), + fork_mode: Some(SpawnAgentForkMode::FullHistory), }, ) .await @@ -609,10 +652,23 @@ async fn spawn_agent_can_fork_parent_thread_history() { .expect("child thread should be registered"); assert_ne!(child_thread_id, parent_thread_id); let history = child_thread.codex.session.clone_history().await; - assert!(history_contains_text( + let expected_history = [ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "parent seed context".to_string(), + }], + end_turn: None, + phase: None, + }, + assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)), + ]; + assert_eq!( history.raw_items(), - "parent seed context" - )); + &expected_history, + "forked child history should keep only parent user messages and assistant final answers" + ); let expected = ( child_thread_id, @@ -643,22 +699,119 @@ async fn spawn_agent_can_fork_parent_thread_history() { } #[tokio::test] -async fn spawn_agent_fork_injects_output_for_parent_spawn_call() { +async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() { let harness = AgentControlHarness::new().await; let (parent_thread_id, parent_thread) = harness.start_thread().await; let turn_context = parent_thread.codex.session.new_default_turn().await; - let parent_spawn_call_id = "spawn-call-1".to_string(); - let parent_spawn_call = ResponseItem::FunctionCall { - id: None, - name: "spawn_agent".to_string(), - namespace: None, - arguments: "{}".to_string(), - call_id: parent_spawn_call_id.clone(), - }; + let parent_spawn_call_id = "spawn-call-unflushed".to_string(); parent_thread .codex .session - .record_conversation_items(turn_context.as_ref(), &[parent_spawn_call]) + .record_conversation_items( + turn_context.as_ref(), + &[ + assistant_message("unflushed final answer", Some(MessagePhase::FinalAnswer)), + spawn_agent_call(&parent_spawn_call_id), + ], + ) + .await; + + let child_thread_id = harness + .control + .spawn_agent_with_metadata( + harness.config.clone(), + text_input("child task"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()), + fork_mode: Some(SpawnAgentForkMode::FullHistory), + }, + ) + .await + .expect("forked spawn should flush parent rollout before loading history") + .thread_id; + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered"); + let history = child_thread.codex.session.clone_history().await; + assert!( + history_contains_text(history.raw_items(), "unflushed final answer"), + "forked child history should include unflushed assistant final answers after flushing the parent rollout" + ); + + let _ = harness + .control + .shutdown_live_agent(child_thread_id) + .await + .expect("child shutdown should submit"); + let _ = parent_thread + .submit(Op::Shutdown {}) + .await + .expect("parent shutdown should submit"); +} + +#[tokio::test] +async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, parent_thread) = harness.start_thread().await; + + parent_thread + .inject_user_message_without_turn("old parent context".to_string()) + .await; + let queued_communication = InterAgentCommunication::new( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + Vec::new(), + "queued message".to_string(), + /*trigger_turn*/ false, + ); + let queued_turn_context = parent_thread.codex.session.new_default_turn().await; + parent_thread + .codex + .session + .record_conversation_items( + queued_turn_context.as_ref(), + &[queued_communication.to_response_input_item().into()], + ) + .await; + + let triggered_communication = InterAgentCommunication::new( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + Vec::new(), + "triggered context".to_string(), + /*trigger_turn*/ true, + ); + let triggered_turn_context = parent_thread.codex.session.new_default_turn().await; + parent_thread + .codex + .session + .record_conversation_items( + triggered_turn_context.as_ref(), + &[triggered_communication.to_response_input_item().into()], + ) + .await; + parent_thread + .inject_user_message_without_turn("current parent task".to_string()) + .await; + let spawn_turn_context = parent_thread.codex.session.new_default_turn().await; + let parent_spawn_call_id = "spawn-call-last-n".to_string(); + parent_thread + .codex + .session + .record_conversation_items( + spawn_turn_context.as_ref(), + &[spawn_agent_call(&parent_spawn_call_id)], + ) .await; parent_thread .codex @@ -681,10 +834,11 @@ async fn spawn_agent_fork_injects_output_for_parent_spawn_call() { })), SpawnAgentOptions { fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()), + fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)), }, ) .await - .expect("forked spawn should succeed") + .expect("forked spawn should keep only the last two turns") .thread_id; let child_thread = harness @@ -693,100 +847,23 @@ async fn spawn_agent_fork_injects_output_for_parent_spawn_call() { .await .expect("child thread should be registered"); let history = child_thread.codex.session.clone_history().await; - let injected_output = history.raw_items().iter().find_map(|item| match item { - ResponseItem::FunctionCallOutput { call_id, output } - if call_id == &parent_spawn_call_id => - { - Some(output) - } - _ => None, - }); - let injected_output = - injected_output.expect("forked child should contain synthetic tool output"); - assert_eq!( - injected_output.text_content(), - Some(FORKED_SPAWN_AGENT_OUTPUT_MESSAGE) + + assert!( + !history_contains_text(history.raw_items(), "old parent context"), + "forked child history should drop parent context outside the requested last-N turn window" + ); + assert!( + !history_contains_text(history.raw_items(), "queued message"), + "forked child history should drop queued inter-agent messages outside the requested last-N turn window" + ); + assert!( + !history_contains_text(history.raw_items(), "triggered context"), + "forked child history should filter assistant inter-agent messages even when they fall inside the requested last-N turn window" + ); + assert!( + history_contains_text(history.raw_items(), "current parent task"), + "forked child history should keep the parent user message from the requested last-N turn window" ); - assert_eq!(injected_output.success, Some(true)); - - let _ = harness - .control - .shutdown_live_agent(child_thread_id) - .await - .expect("child shutdown should submit"); - let _ = parent_thread - .submit(Op::Shutdown {}) - .await - .expect("parent shutdown should submit"); -} - -#[tokio::test] -async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() { - let harness = AgentControlHarness::new().await; - let (parent_thread_id, parent_thread) = harness.start_thread().await; - let turn_context = parent_thread.codex.session.new_default_turn().await; - let parent_spawn_call_id = "spawn-call-unflushed".to_string(); - let parent_spawn_call = ResponseItem::FunctionCall { - id: None, - name: "spawn_agent".to_string(), - namespace: None, - arguments: "{}".to_string(), - call_id: parent_spawn_call_id.clone(), - }; - parent_thread - .codex - .session - .record_conversation_items(turn_context.as_ref(), &[parent_spawn_call]) - .await; - - let child_thread_id = harness - .control - .spawn_agent_with_metadata( - harness.config.clone(), - text_input("child task"), - Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth: 1, - agent_path: None, - agent_nickname: None, - agent_role: None, - })), - SpawnAgentOptions { - fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()), - }, - ) - .await - .expect("forked spawn should flush parent rollout before loading history") - .thread_id; - - let child_thread = harness - .manager - .get_thread(child_thread_id) - .await - .expect("child thread should be registered"); - let history = child_thread.codex.session.clone_history().await; - - let mut parent_call_index = None; - let mut injected_output_index = None; - for (idx, item) in history.raw_items().iter().enumerate() { - match item { - ResponseItem::FunctionCall { call_id, .. } if call_id == &parent_spawn_call_id => { - parent_call_index = Some(idx); - } - ResponseItem::FunctionCallOutput { call_id, .. } - if call_id == &parent_spawn_call_id => - { - injected_output_index = Some(idx); - } - _ => {} - } - } - - let parent_call_index = - parent_call_index.expect("forked child should include the parent spawn_agent call"); - let injected_output_index = injected_output_index - .expect("forked child should include synthetic output for the parent spawn_agent call"); - assert!(parent_call_index < injected_output_index); let _ = harness .control @@ -811,6 +888,9 @@ async fn spawn_agent_respects_max_threads_limit() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.clone(), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( + /*exec_server_url*/ None, + )), ); let control = manager.agent_control(); @@ -820,12 +900,20 @@ async fn spawn_agent_respects_max_threads_limit() { .expect("start thread"); let first_agent_id = control - .spawn_agent(config.clone(), text_input("hello"), None) + .spawn_agent( + config.clone(), + text_input("hello"), + /*session_source*/ None, + ) .await .expect("spawn_agent should succeed"); let err = control - .spawn_agent(config, text_input("hello again"), None) + .spawn_agent( + config, + text_input("hello again"), + /*session_source*/ None, + ) .await .expect_err("spawn_agent should respect max threads"); let CodexErr::AgentLimitReached { @@ -854,11 +942,18 @@ async fn spawn_agent_releases_slot_after_shutdown() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.clone(), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( + /*exec_server_url*/ None, + )), ); let control = manager.agent_control(); let first_agent_id = control - .spawn_agent(config.clone(), text_input("hello"), None) + .spawn_agent( + config.clone(), + text_input("hello"), + /*session_source*/ None, + ) .await .expect("spawn_agent should succeed"); let _ = control @@ -867,7 +962,11 @@ async fn spawn_agent_releases_slot_after_shutdown() { .expect("shutdown agent"); let second_agent_id = control - .spawn_agent(config.clone(), text_input("hello again"), None) + .spawn_agent( + config.clone(), + text_input("hello again"), + /*session_source*/ None, + ) .await .expect("spawn_agent should succeed after shutdown"); let _ = control @@ -888,17 +987,28 @@ async fn spawn_agent_limit_shared_across_clones() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.clone(), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( + /*exec_server_url*/ None, + )), ); let control = manager.agent_control(); let cloned = control.clone(); let first_agent_id = cloned - .spawn_agent(config.clone(), text_input("hello"), None) + .spawn_agent( + config.clone(), + text_input("hello"), + /*session_source*/ None, + ) .await .expect("spawn_agent should succeed"); let err = control - .spawn_agent(config, text_input("hello again"), None) + .spawn_agent( + config, + text_input("hello again"), + /*session_source*/ None, + ) .await .expect_err("spawn_agent should respect shared guard"); let CodexErr::AgentLimitReached { max_threads } = err else { @@ -924,11 +1034,18 @@ async fn resume_agent_respects_max_threads_limit() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.clone(), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( + /*exec_server_url*/ None, + )), ); let control = manager.agent_control(); let resumable_id = control - .spawn_agent(config.clone(), text_input("hello"), None) + .spawn_agent( + config.clone(), + text_input("hello"), + /*session_source*/ None, + ) .await .expect("spawn_agent should succeed"); let _ = control @@ -937,7 +1054,11 @@ async fn resume_agent_respects_max_threads_limit() { .expect("shutdown resumable thread"); let active_id = control - .spawn_agent(config.clone(), text_input("occupy"), None) + .spawn_agent( + config.clone(), + text_input("occupy"), + /*session_source*/ None, + ) .await .expect("spawn_agent should succeed for active slot"); @@ -971,6 +1092,9 @@ async fn resume_agent_releases_slot_after_resume_failure() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.clone(), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( + /*exec_server_url*/ None, + )), ); let control = manager.agent_control(); @@ -980,7 +1104,7 @@ async fn resume_agent_releases_slot_after_resume_failure() { .expect_err("resume should fail for missing rollout path"); let resumed_id = control - .spawn_agent(config, text_input("hello"), None) + .spawn_agent(config, text_input("hello"), /*session_source*/ None) .await .expect("spawn should succeed after failed resume"); let _ = control @@ -1081,6 +1205,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1118,7 +1244,7 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { AgentPath::root(), Vec::new(), "done".to_string(), - true, + /*trigger_turn*/ true, ) )); assert!(!has_subagent_notification(&root_history_items)); @@ -1165,6 +1291,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, }), ) .await; @@ -1181,7 +1309,7 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { worker_path.clone(), Vec::new(), expected_message.clone(), - false, + /*trigger_turn*/ false, ), }, ); @@ -1216,7 +1344,7 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { AgentPath::root(), Vec::new(), expected_message, - false, + /*trigger_turn*/ false, ) )); } @@ -1237,7 +1365,7 @@ async fn completion_watcher_notifies_parent_when_child_is_missing() { agent_role: Some("explorer".to_string()), })), child_thread_id.to_string(), - None, + /*child_agent_path*/ None, ); assert_eq!(wait_for_subagent_notification(&parent_thread).await, true); @@ -1361,6 +1489,9 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() { CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.clone(), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::new( + /*exec_server_url*/ None, + )), ); let control = manager.agent_control(); let harness = AgentControlHarness { @@ -1495,7 +1626,11 @@ async fn resume_agent_from_rollout_reads_archived_rollout_path() { let harness = AgentControlHarness::new().await; let child_thread_id = harness .control - .spawn_agent(harness.config.clone(), text_input("hello"), None) + .spawn_agent( + harness.config.clone(), + text_input("hello"), + /*session_source*/ None, + ) .await .expect("child spawn should succeed"); @@ -1552,6 +1687,132 @@ async fn resume_agent_from_rollout_reads_archived_rollout_path() { .expect("resumed child shutdown should succeed"); } +#[tokio::test] +async fn list_agent_subtree_thread_ids_includes_anonymous_and_closed_descendants() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, _parent_thread) = harness.start_thread().await; + let worker_path = AgentPath::root().join("worker").expect("worker path"); + let reviewer_path = AgentPath::root().join("reviewer").expect("reviewer path"); + + let worker_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello worker"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: Some(worker_path.clone()), + agent_nickname: None, + agent_role: Some("worker".to_string()), + })), + ) + .await + .expect("worker spawn should succeed"); + let worker_child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello worker child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: worker_thread_id, + depth: 2, + agent_path: Some( + worker_path + .join("child") + .expect("worker child path should be valid"), + ), + agent_nickname: None, + agent_role: Some("worker".to_string()), + })), + ) + .await + .expect("worker child spawn should succeed"); + let no_path_child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello anonymous child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: worker_thread_id, + depth: 2, + agent_path: None, + agent_nickname: None, + agent_role: Some("worker".to_string()), + })), + ) + .await + .expect("no-path child spawn should succeed"); + let no_path_grandchild_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello anonymous grandchild"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: no_path_child_thread_id, + depth: 3, + agent_path: None, + agent_nickname: None, + agent_role: Some("worker".to_string()), + })), + ) + .await + .expect("no-path grandchild spawn should succeed"); + let _reviewer_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("hello reviewer"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: Some(reviewer_path), + agent_nickname: None, + agent_role: Some("reviewer".to_string()), + })), + ) + .await + .expect("reviewer spawn should succeed"); + + let _ = harness + .control + .shutdown_live_agent(no_path_grandchild_thread_id) + .await + .expect("no-path grandchild shutdown should succeed"); + + let mut worker_subtree_thread_ids = harness + .manager + .list_agent_subtree_thread_ids(worker_thread_id) + .await + .expect("worker subtree thread ids should load"); + worker_subtree_thread_ids.sort_by_key(ToString::to_string); + let mut expected_worker_subtree_thread_ids = vec![ + worker_thread_id, + worker_child_thread_id, + no_path_child_thread_id, + no_path_grandchild_thread_id, + ]; + expected_worker_subtree_thread_ids.sort_by_key(ToString::to_string); + assert_eq!( + worker_subtree_thread_ids, + expected_worker_subtree_thread_ids + ); + + let mut no_path_child_subtree_thread_ids = harness + .manager + .list_agent_subtree_thread_ids(no_path_child_thread_id) + .await + .expect("no-path subtree thread ids should load"); + no_path_child_subtree_thread_ids.sort_by_key(ToString::to_string); + let mut expected_no_path_child_subtree_thread_ids = + vec![no_path_child_thread_id, no_path_grandchild_thread_id]; + expected_no_path_child_subtree_thread_ids.sort_by_key(ToString::to_string); + assert_eq!( + no_path_child_subtree_thread_ids, + expected_no_path_child_subtree_thread_ids + ); +} + #[tokio::test] async fn shutdown_agent_tree_closes_live_descendants() { let harness = AgentControlHarness::new().await; diff --git a/codex-rs/core/src/agent/mailbox.rs b/codex-rs/core/src/agent/mailbox.rs new file mode 100644 index 0000000000..c328236475 --- /dev/null +++ b/codex-rs/core/src/agent/mailbox.rs @@ -0,0 +1,161 @@ +use codex_protocol::protocol::InterAgentCommunication; +use std::collections::VecDeque; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use tokio::sync::mpsc; +use tokio::sync::watch; + +#[cfg(test)] +use codex_protocol::AgentPath; + +pub(crate) struct Mailbox { + tx: mpsc::UnboundedSender, + next_seq: AtomicU64, + seq_tx: watch::Sender, +} + +pub(crate) struct MailboxReceiver { + rx: mpsc::UnboundedReceiver, + pending_mails: VecDeque, +} + +impl Mailbox { + pub(crate) fn new() -> (Self, MailboxReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + let (seq_tx, _) = watch::channel(0); + ( + Self { + tx, + next_seq: AtomicU64::new(0), + seq_tx, + }, + MailboxReceiver { + rx, + pending_mails: VecDeque::new(), + }, + ) + } + + pub(crate) fn subscribe(&self) -> watch::Receiver { + self.seq_tx.subscribe() + } + + pub(crate) fn send(&self, communication: InterAgentCommunication) -> u64 { + let seq = self.next_seq.fetch_add(1, Ordering::Relaxed) + 1; + let _ = self.tx.send(communication); + self.seq_tx.send_replace(seq); + seq + } +} + +impl MailboxReceiver { + fn sync_pending_mails(&mut self) { + while let Ok(mail) = self.rx.try_recv() { + self.pending_mails.push_back(mail); + } + } + + pub(crate) fn has_pending(&mut self) -> bool { + self.sync_pending_mails(); + !self.pending_mails.is_empty() + } + + pub(crate) fn has_pending_trigger_turn(&mut self) -> bool { + self.sync_pending_mails(); + self.pending_mails.iter().any(|mail| mail.trigger_turn) + } + + pub(crate) fn drain(&mut self) -> Vec { + self.sync_pending_mails(); + self.pending_mails.drain(..).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn make_mail( + author: AgentPath, + recipient: AgentPath, + content: &str, + trigger_turn: bool, + ) -> InterAgentCommunication { + InterAgentCommunication::new( + author, + recipient, + Vec::new(), + content.to_string(), + trigger_turn, + ) + } + + #[tokio::test] + async fn mailbox_assigns_monotonic_sequence_numbers() { + let (mailbox, _receiver) = Mailbox::new(); + let mut seq_rx = mailbox.subscribe(); + + let seq_a = mailbox.send(make_mail( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + "one", + /*trigger_turn*/ false, + )); + let seq_b = mailbox.send(make_mail( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + "two", + /*trigger_turn*/ false, + )); + + seq_rx.changed().await.expect("first seq update"); + assert_eq!(*seq_rx.borrow(), seq_b); + assert_eq!(seq_a, 1); + assert_eq!(seq_b, 2); + } + + #[tokio::test] + async fn mailbox_drains_in_delivery_order() { + let (mailbox, mut receiver) = Mailbox::new(); + let mail_one = make_mail( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + "one", + /*trigger_turn*/ false, + ); + let mail_two = make_mail( + AgentPath::try_from("/root/worker").expect("agent path"), + AgentPath::root(), + "two", + /*trigger_turn*/ false, + ); + + mailbox.send(mail_one.clone()); + mailbox.send(mail_two.clone()); + + assert_eq!(receiver.drain(), vec![mail_one, mail_two]); + assert!(!receiver.has_pending()); + } + + #[tokio::test] + async fn mailbox_tracks_pending_trigger_turn_mail() { + let (mailbox, mut receiver) = Mailbox::new(); + + mailbox.send(make_mail( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + "queued", + /*trigger_turn*/ false, + )); + assert!(!receiver.has_pending_trigger_turn()); + + mailbox.send(make_mail( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + "wake", + /*trigger_turn*/ true, + )); + assert!(receiver.has_pending_trigger_turn()); + } +} diff --git a/codex-rs/core/src/agent/mod.rs b/codex-rs/core/src/agent/mod.rs index 350962dc08..a60fc3004a 100644 --- a/codex-rs/core/src/agent/mod.rs +++ b/codex-rs/core/src/agent/mod.rs @@ -1,11 +1,14 @@ pub(crate) mod agent_resolver; pub(crate) mod control; +pub(crate) mod mailbox; mod registry; pub(crate) mod role; pub(crate) mod status; pub(crate) use codex_protocol::protocol::AgentStatus; pub(crate) use control::AgentControl; +pub(crate) use mailbox::Mailbox; +pub(crate) use mailbox::MailboxReceiver; pub(crate) use registry::exceeds_thread_spawn_depth_limit; pub(crate) use registry::next_thread_spawn_depth; pub(crate) use status::agent_status_from_event; diff --git a/codex-rs/core/src/agent/registry.rs b/codex-rs/core/src/agent/registry.rs index f78c8d08bc..1acd73085f 100644 --- a/codex-rs/core/src/agent/registry.rs +++ b/codex-rs/core/src/agent/registry.rs @@ -1,7 +1,7 @@ -use crate::error::CodexErr; -use crate::error::Result; use codex_protocol::AgentPath; use codex_protocol::ThreadId; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use rand::prelude::IndexedRandom; @@ -220,7 +220,7 @@ impl AgentRegistry { } else { active_agents.used_agent_nicknames.clear(); active_agents.nickname_reset_count += 1; - if let Some(metrics) = codex_otel::metrics::global() { + if let Some(metrics) = codex_otel::global() { let _ = metrics.counter( "codex.multi_agent.nickname_pool_reset", /*inc*/ 1, diff --git a/codex-rs/core/src/agent/registry_tests.rs b/codex-rs/core/src/agent/registry_tests.rs index 43d91952a7..fc172fb336 100644 --- a/codex-rs/core/src/agent/registry_tests.rs +++ b/codex-rs/core/src/agent/registry_tests.rs @@ -16,11 +16,26 @@ fn agent_metadata(thread_id: ThreadId) -> AgentMetadata { #[test] fn format_agent_nickname_adds_ordinals_after_reset() { - assert_eq!(format_agent_nickname("Plato", 0), "Plato"); - assert_eq!(format_agent_nickname("Plato", 1), "Plato the 2nd"); - assert_eq!(format_agent_nickname("Plato", 2), "Plato the 3rd"); - assert_eq!(format_agent_nickname("Plato", 10), "Plato the 11th"); - assert_eq!(format_agent_nickname("Plato", 20), "Plato the 21st"); + assert_eq!( + format_agent_nickname("Plato", /*nickname_reset_count*/ 0), + "Plato" + ); + assert_eq!( + format_agent_nickname("Plato", /*nickname_reset_count*/ 1), + "Plato the 2nd" + ); + assert_eq!( + format_agent_nickname("Plato", /*nickname_reset_count*/ 2), + "Plato the 3rd" + ); + assert_eq!( + format_agent_nickname("Plato", /*nickname_reset_count*/ 10), + "Plato the 11th" + ); + assert_eq!( + format_agent_nickname("Plato", /*nickname_reset_count*/ 20), + "Plato the 21st" + ); } #[test] @@ -39,7 +54,10 @@ fn thread_spawn_depth_increments_and_enforces_limit() { }); let child_depth = next_thread_spawn_depth(&session_source); assert_eq!(child_depth, 2); - assert!(exceeds_thread_spawn_depth_limit(child_depth, 1)); + assert!(exceeds_thread_spawn_depth_limit( + child_depth, + /*max_depth*/ 1 + )); } #[test] @@ -47,7 +65,9 @@ fn non_thread_spawn_subagents_default_to_depth_zero() { let session_source = SessionSource::SubAgent(SubAgentSource::Review); assert_eq!(session_depth(&session_source), 0); assert_eq!(next_thread_spawn_depth(&session_source), 1); - assert!(!exceeds_thread_spawn_depth_limit(1, 1)); + assert!(!exceeds_thread_spawn_depth_limit( + /*depth*/ 1, /*max_depth*/ 1 + )); } #[test] @@ -142,14 +162,18 @@ fn release_is_idempotent_for_registered_threads() { #[test] fn failed_spawn_keeps_nickname_marked_used() { let registry = Arc::new(AgentRegistry::default()); - let mut reservation = registry.reserve_spawn_slot(None).expect("reserve slot"); + let mut reservation = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve slot"); let agent_nickname = reservation .reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None) .expect("reserve agent name"); assert_eq!(agent_nickname, "alpha"); drop(reservation); - let mut reservation = registry.reserve_spawn_slot(None).expect("reserve slot"); + let mut reservation = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve slot"); let agent_nickname = reservation .reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None) .expect("unused name should still be preferred"); @@ -160,7 +184,7 @@ fn failed_spawn_keeps_nickname_marked_used() { fn agent_nickname_resets_used_pool_when_exhausted() { let registry = Arc::new(AgentRegistry::default()); let mut first = registry - .reserve_spawn_slot(None) + .reserve_spawn_slot(/*max_threads*/ None) .expect("reserve first slot"); let first_name = first .reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None) @@ -170,7 +194,7 @@ fn agent_nickname_resets_used_pool_when_exhausted() { assert_eq!(first_name, "alpha"); let mut second = registry - .reserve_spawn_slot(None) + .reserve_spawn_slot(/*max_threads*/ None) .expect("reserve second slot"); let second_name = second .reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None) @@ -188,7 +212,7 @@ fn released_nickname_stays_used_until_pool_reset() { let registry = Arc::new(AgentRegistry::default()); let mut first = registry - .reserve_spawn_slot(None) + .reserve_spawn_slot(/*max_threads*/ None) .expect("reserve first slot"); let first_name = first .reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None) @@ -200,7 +224,7 @@ fn released_nickname_stays_used_until_pool_reset() { registry.release_spawned_thread(first_id); let mut second = registry - .reserve_spawn_slot(None) + .reserve_spawn_slot(/*max_threads*/ None) .expect("reserve second slot"); let second_name = second .reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None) @@ -211,7 +235,7 @@ fn released_nickname_stays_used_until_pool_reset() { registry.release_spawned_thread(second_id); let mut third = registry - .reserve_spawn_slot(None) + .reserve_spawn_slot(/*max_threads*/ None) .expect("reserve third slot"); let third_name = third .reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None) @@ -230,7 +254,7 @@ fn repeated_resets_advance_the_ordinal_suffix() { let registry = Arc::new(AgentRegistry::default()); let mut first = registry - .reserve_spawn_slot(None) + .reserve_spawn_slot(/*max_threads*/ None) .expect("reserve first slot"); let first_name = first .reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None) @@ -241,7 +265,7 @@ fn repeated_resets_advance_the_ordinal_suffix() { registry.release_spawned_thread(first_id); let mut second = registry - .reserve_spawn_slot(None) + .reserve_spawn_slot(/*max_threads*/ None) .expect("reserve second slot"); let second_name = second .reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None) @@ -252,7 +276,7 @@ fn repeated_resets_advance_the_ordinal_suffix() { registry.release_spawned_thread(second_id); let mut third = registry - .reserve_spawn_slot(None) + .reserve_spawn_slot(/*max_threads*/ None) .expect("reserve third slot"); let third_name = third .reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None) @@ -282,7 +306,7 @@ fn register_root_thread_indexes_root_path() { fn reserved_agent_path_is_released_when_spawn_fails() { let registry = Arc::new(AgentRegistry::default()); let mut first = registry - .reserve_spawn_slot(None) + .reserve_spawn_slot(/*max_threads*/ None) .expect("reserve first slot"); first .reserve_agent_path(&agent_path("/root/researcher")) @@ -290,7 +314,7 @@ fn reserved_agent_path_is_released_when_spawn_fails() { drop(first); let mut second = registry - .reserve_spawn_slot(None) + .reserve_spawn_slot(/*max_threads*/ None) .expect("reserve second slot"); second .reserve_agent_path(&agent_path("/root/researcher")) @@ -301,7 +325,9 @@ fn reserved_agent_path_is_released_when_spawn_fails() { fn committed_agent_path_is_indexed_until_release() { let registry = Arc::new(AgentRegistry::default()); let thread_id = ThreadId::new(); - let mut reservation = registry.reserve_spawn_slot(None).expect("reserve slot"); + let mut reservation = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve slot"); reservation .reserve_agent_path(&agent_path("/root/researcher")) .expect("reserve path"); diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 5cb9af2e6a..61847f49ba 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -17,6 +17,7 @@ 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::config_toml::ConfigToml; use std::collections::BTreeMap; use std::collections::BTreeSet; use std::path::Path; @@ -63,6 +64,12 @@ async fn apply_role_to_config_inner( return Ok(()); }; let role_layer_toml = load_role_layer_toml(config, config_file, is_built_in, role_name).await?; + if role_layer_toml + .as_table() + .is_some_and(toml::map::Map::is_empty) + { + return Ok(()); + } let (preserve_current_profile, preserve_current_provider) = preservation_policy(config, &role_layer_toml); @@ -223,7 +230,7 @@ mod reload { fn deserialize_effective_config( config: &Config, config_layer_stack: &ConfigLayerStack, - ) -> anyhow::Result { + ) -> anyhow::Result { Ok(deserialize_config_toml_with_base( config_layer_stack.effective_config(), &config.codex_home, diff --git a/codex-rs/core/src/agent/role_tests.rs b/codex-rs/core/src/agent/role_tests.rs index c96bd2acf7..d68376ddd6 100644 --- a/codex-rs/core/src/agent/role_tests.rs +++ b/codex-rs/core/src/agent/role_tests.rs @@ -40,7 +40,10 @@ async fn write_role_config(home: &TempDir, name: &str, contents: &str) -> PathBu fn session_flags_layer_count(config: &Config) -> usize { config .config_layer_stack - .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true) + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) .into_iter() .filter(|layer| layer.name == ConfigLayerSource::SessionFlags) .count() @@ -51,7 +54,7 @@ async fn apply_role_defaults_to_default_and_leaves_config_unchanged() { let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await; let before = config.clone(); - apply_role_to_config(&mut config, None) + apply_role_to_config(&mut config, /*role_name*/ None) .await .expect("default role should apply"); @@ -84,6 +87,22 @@ async fn apply_explorer_role_sets_model_and_adds_session_flags_layer() { assert_eq!(session_flags_layer_count(&config), before_layers + 1); } +#[tokio::test] +async fn apply_empty_explorer_role_preserves_current_model_and_reasoning_effort() { + let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await; + let before_layers = session_flags_layer_count(&config); + config.model = Some("gpt-5.4-mini".to_string()); + config.model_reasoning_effort = Some(ReasoningEffort::High); + + apply_role_to_config(&mut config, Some("explorer")) + .await + .expect("explorer role should apply"); + + assert_eq!(config.model.as_deref(), Some("gpt-5.4-mini")); + assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High)); + assert_eq!(session_flags_layer_count(&config), before_layers); +} + #[tokio::test] async fn apply_role_returns_unavailable_for_missing_user_role_file() { let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await; @@ -529,7 +548,10 @@ writable_roots = ["./sandbox-root"] let role_layer = config .config_layer_stack - .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true) + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) .into_iter() .rfind(|layer| layer.name == ConfigLayerSource::SessionFlags) .expect("expected a session flags layer"); @@ -630,7 +652,10 @@ enabled = false .expect("custom role should apply"); let plugins_manager = Arc::new(PluginsManager::new(home.path().to_path_buf())); - let skills_manager = SkillsManager::new(home.path().to_path_buf(), true); + let skills_manager = SkillsManager::new( + home.path().to_path_buf(), + /*bundled_skills_enabled*/ true, + ); let plugin_outcome = plugins_manager.plugins_for_config(&config); let effective_skill_roots = plugin_outcome.effective_skill_roots(); let skills_input = skills_load_input_from_config(&config, effective_skill_roots); diff --git a/codex-rs/core/src/apply_patch.rs b/codex-rs/core/src/apply_patch.rs index 9a09ae9f09..0810d319b8 100644 --- a/codex-rs/core/src/apply_patch.rs +++ b/codex-rs/core/src/apply_patch.rs @@ -1,12 +1,12 @@ use crate::codex::TurnContext; use crate::function_tool::FunctionCallError; -use crate::protocol::FileChange; -use crate::protocol::FileSystemSandboxPolicy; use crate::safety::SafetyCheck; use crate::safety::assess_patch_safety; use crate::tools::sandboxing::ExecApprovalRequirement; use codex_apply_patch::ApplyPatchAction; use codex_apply_patch::ApplyPatchFileChange; +use codex_protocol::protocol::FileChange; +use codex_protocol::protocol::FileSystemSandboxPolicy; use std::collections::HashMap; use std::path::PathBuf; diff --git a/codex-rs/core/src/apply_patch_tests.rs b/codex-rs/core/src/apply_patch_tests.rs index 1b9e722d5a..c0190c3708 100644 --- a/codex-rs/core/src/apply_patch_tests.rs +++ b/codex-rs/core/src/apply_patch_tests.rs @@ -1,4 +1,5 @@ use super::*; +use core_test_support::PathBufExt; use pretty_assertions::assert_eq; use tempfile::tempdir; @@ -6,14 +7,14 @@ use tempfile::tempdir; #[test] fn convert_apply_patch_maps_add_variant() { let tmp = tempdir().expect("tmp"); - let p = tmp.path().join("a.txt"); + let p = tmp.path().join("a.txt").abs(); // Create an action with a single Add change let action = ApplyPatchAction::new_add_for_test(&p, "hello".to_string()); let got = convert_apply_patch_to_protocol(&action); assert_eq!( - got.get(&p), + got.get(p.as_path()), Some(&FileChange::Add { content: "hello".to_string() }) diff --git a/codex-rs/core/src/apps/render.rs b/codex-rs/core/src/apps/render.rs index 72accdba3b..fe23a09f8d 100644 --- a/codex-rs/core/src/apps/render.rs +++ b/codex-rs/core/src/apps/render.rs @@ -1,5 +1,5 @@ -use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_app_server_protocol::AppInfo; +use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_protocol::protocol::APPS_INSTRUCTIONS_CLOSE_TAG; use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG; diff --git a/codex-rs/core/src/arc_monitor.rs b/codex-rs/core/src/arc_monitor.rs index ba9a886de6..13f33f6c90 100644 --- a/codex-rs/core/src/arc_monitor.rs +++ b/codex-rs/core/src/arc_monitor.rs @@ -8,8 +8,9 @@ use tracing::warn; use crate::codex::Session; use crate::codex::TurnContext; use crate::compact::content_items_to_text; -use crate::default_client::build_reqwest_client; use crate::event_mapping::is_contextual_user_message_content; +use codex_login::CodexAuth; +use codex_login::default_client::build_reqwest_client; use codex_protocol::models::MessagePhase; use codex_protocol::models::ResponseItem; @@ -147,10 +148,7 @@ pub(crate) async fn monitor_action( .timeout(ARC_MONITOR_TIMEOUT) .json(&body) .bearer_auth(token); - if let Some(account_id) = auth - .as_ref() - .and_then(crate::auth::CodexAuth::get_account_id) - { + if let Some(account_id) = auth.as_ref().and_then(CodexAuth::get_account_id) { request = request.header("chatgpt-account-id", account_id); } diff --git a/codex-rs/core/src/arc_monitor_tests.rs b/codex-rs/core/src/arc_monitor_tests.rs index ab88fddca9..a7ba239973 100644 --- a/codex-rs/core/src/arc_monitor_tests.rs +++ b/codex-rs/core/src/arc_monitor_tests.rs @@ -253,7 +253,7 @@ async fn monitor_action_posts_expected_arc_request() { let server = MockServer::start().await; let (session, mut turn_context) = make_session_and_context().await; turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth( - crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(), + codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(), )); turn_context.developer_instructions = Some("Developer policy".to_string()); turn_context.user_instructions = Some("User policy".to_string()); @@ -407,7 +407,7 @@ async fn monitor_action_rejects_legacy_response_fields() { let (session, mut turn_context) = make_session_and_context().await; turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth( - crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(), + codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(), )); let mut config = (*turn_context.config).clone(); config.chatgpt_base_url = server.uri(); diff --git a/codex-rs/core/src/bin/config_schema.rs b/codex-rs/core/src/bin/config_schema.rs index 8d33df42e1..f92ce62307 100644 --- a/codex-rs/core/src/bin/config_schema.rs +++ b/codex-rs/core/src/bin/config_schema.rs @@ -15,6 +15,6 @@ fn main() -> Result<()> { let out_path = args .out .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.schema.json")); - codex_core::config::schema::write_config_schema(&out_path)?; + codex_config::schema::write_config_schema(&out_path)?; Ok(()) } diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index e38ff3a562..80ad24be5b 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -28,20 +28,18 @@ use std::sync::Arc; use std::sync::Mutex as StdMutex; use std::sync::OnceLock; use std::sync::atomic::AtomicBool; +use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; -use crate::api_bridge::CoreAuthProvider; -use crate::api_bridge::auth_provider_from_auth; -use crate::api_bridge::map_api_error; -use crate::auth::UnauthorizedRecovery; -use crate::auth_env_telemetry::AuthEnvTelemetry; -use crate::auth_env_telemetry::collect_auth_env_telemetry; +use codex_api::ApiError; use codex_api::CompactClient as ApiCompactClient; use codex_api::CompactionInput as ApiCompactionInput; +use codex_api::Compression; use codex_api::MemoriesClient as ApiMemoriesClient; use codex_api::MemorySummarizeInput as ApiMemorySummarizeInput; use codex_api::MemorySummarizeOutput as ApiMemorySummarizeOutput; use codex_api::RawMemory as ApiRawMemory; +use codex_api::Reasoning; use codex_api::RequestTelemetry; use codex_api::ReqwestTransport; use codex_api::ResponseCreateWsRequest; @@ -50,16 +48,19 @@ use codex_api::ResponsesClient as ApiResponsesClient; use codex_api::ResponsesOptions as ApiResponsesOptions; use codex_api::ResponsesWebsocketClient as ApiWebSocketResponsesClient; use codex_api::ResponsesWebsocketConnection as ApiWebSocketConnection; +use codex_api::ResponsesWsRequest; use codex_api::SseTelemetry; use codex_api::TransportError; use codex_api::WebsocketTelemetry; use codex_api::build_conversation_headers; -use codex_api::common::Reasoning; -use codex_api::common::ResponsesWsRequest; use codex_api::create_text_param_for_request; -use codex_api::error::ApiError; -use codex_api::requests::responses::Compression; use codex_api::response_create_client_metadata; +use codex_app_server_protocol::AuthMode; +use codex_login::AuthManager; +use codex_login::CodexAuth; +use codex_login::RefreshTokenError; +use codex_login::UnauthorizedRecovery; +use codex_login::default_client::build_reqwest_client; use codex_otel::SessionTelemetry; use codex_otel::current_span_w3c_trace_context; @@ -71,7 +72,9 @@ use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::W3cTraceContext; +use codex_tools::create_tools_json_for_responses_api; use eventsource_stream::Event; use eventsource_stream::EventStreamError; use futures::StreamExt; @@ -90,31 +93,37 @@ use tracing::instrument; use tracing::trace; use tracing::warn; -use crate::AuthManager; -use crate::auth::AuthMode; -use crate::auth::CodexAuth; -use crate::auth::RefreshTokenError; use crate::client_common::Prompt; use crate::client_common::ResponseEvent; use crate::client_common::ResponseStream; -use crate::default_client::build_reqwest_client; -use crate::error::CodexErr; -use crate::error::Result; use crate::flags::CODEX_RS_SSE_FIXTURE; -use crate::model_provider_info::ModelProviderInfo; -use crate::model_provider_info::WireApi; -use crate::response_debug_context::extract_response_debug_context; -use crate::response_debug_context::extract_response_debug_context_from_api_error; -use crate::response_debug_context::telemetry_api_error_message; -use crate::response_debug_context::telemetry_transport_error_message; -use crate::tools::spec::create_tools_json_for_responses_api; -use crate::util::FeedbackRequestTags; use crate::util::emit_feedback_auth_recovery_tags; -use crate::util::emit_feedback_request_tags_with_auth_env; +use codex_api::CoreAuthProvider; +use codex_api::map_api_error; +use codex_feedback::FeedbackRequestTags; +use codex_feedback::emit_feedback_request_tags_with_auth_env; +use codex_login::api_bridge::auth_provider_from_auth; +use codex_login::auth_env_telemetry::AuthEnvTelemetry; +use codex_login::auth_env_telemetry::collect_auth_env_telemetry; +use codex_login::provider_auth::auth_manager_for_provider; +#[cfg(test)] +use codex_model_provider_info::DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS; +use codex_model_provider_info::ModelProviderInfo; +use codex_model_provider_info::WireApi; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result; +use codex_response_debug_context::extract_response_debug_context; +use codex_response_debug_context::extract_response_debug_context_from_api_error; +use codex_response_debug_context::telemetry_api_error_message; +use codex_response_debug_context::telemetry_transport_error_message; pub const OPENAI_BETA_HEADER: &str = "OpenAI-Beta"; +pub const X_CODEX_INSTALLATION_ID_HEADER: &str = "x-codex-installation-id"; pub const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state"; pub const X_CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata"; +pub const X_CODEX_PARENT_THREAD_ID_HEADER: &str = "x-codex-parent-thread-id"; +pub const X_CODEX_WINDOW_ID_HEADER: &str = "x-codex-window-id"; +pub const X_OPENAI_SUBAGENT_HEADER: &str = "x-openai-subagent"; pub const X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER: &str = "x-responsesapi-include-timing-metrics"; const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; @@ -123,7 +132,7 @@ const RESPONSES_COMPACT_ENDPOINT: &str = "/responses/compact"; const MEMORIES_SUMMARIZE_ENDPOINT: &str = "/memories/trace_summarize"; #[cfg(test)] pub(crate) const WEBSOCKET_CONNECT_TIMEOUT: Duration = - Duration::from_millis(crate::model_provider_info::DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS); + Duration::from_millis(DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS); /// Session-scoped state shared by all [`ModelClient`] clones. /// @@ -133,6 +142,8 @@ pub(crate) const WEBSOCKET_CONNECT_TIMEOUT: Duration = struct ModelClientState { auth_manager: Option>, conversation_id: ThreadId, + window_generation: AtomicU64, + installation_id: String, provider: ModelProviderInfo, auth_env_telemetry: AuthEnvTelemetry, session_source: SessionSource, @@ -254,6 +265,7 @@ impl ModelClient { pub fn new( auth_manager: Option>, conversation_id: ThreadId, + installation_id: String, provider: ModelProviderInfo, session_source: SessionSource, model_verbosity: Option, @@ -261,6 +273,7 @@ impl ModelClient { include_timing_metrics: bool, beta_features_header: Option, ) -> Self { + let auth_manager = auth_manager_for_provider(auth_manager, &provider); let codex_api_key_env_enabled = auth_manager .as_ref() .is_some_and(|manager| manager.codex_api_key_env_enabled()); @@ -269,6 +282,8 @@ impl ModelClient { state: Arc::new(ModelClientState { auth_manager, conversation_id, + window_generation: AtomicU64::new(0), + installation_id, provider, auth_env_telemetry, session_source, @@ -294,6 +309,28 @@ impl ModelClient { } } + pub(crate) fn auth_manager(&self) -> Option> { + self.state.auth_manager.clone() + } + + pub(crate) fn set_window_generation(&self, window_generation: u64) { + self.state + .window_generation + .store(window_generation, Ordering::Relaxed); + self.store_cached_websocket_session(WebsocketSession::default()); + } + + pub(crate) fn advance_window_generation(&self) { + self.state.window_generation.fetch_add(1, Ordering::Relaxed); + self.store_cached_websocket_session(WebsocketSession::default()); + } + + fn current_window_id(&self) -> String { + let conversation_id = self.state.conversation_id; + let window_generation = self.state.window_generation.load(Ordering::Relaxed); + format!("{conversation_id}:{window_generation}") + } + fn take_cached_websocket_session(&self) -> WebsocketSession { let mut cached_websocket_session = self .state @@ -366,7 +403,11 @@ impl ModelClient { ApiCompactClient::new(transport, client_setup.api_provider, client_setup.api_auth) .with_telemetry(Some(request_telemetry)); - let instructions = prompt.base_instructions.text.clone(); + let instructions = prompt + .base_instructions + .as_ref() + .map(|base_instructions| base_instructions.text.clone()) + .unwrap_or_default(); let input = prompt.get_formatted_input(); let tools = create_tools_json_for_responses_api(&prompt.tools)?; let reasoning = Self::build_reasoning(model_info, effort, summary); @@ -392,7 +433,11 @@ impl ModelClient { text, }; - let mut extra_headers = self.build_subagent_headers(); + let mut extra_headers = ApiHeaderMap::new(); + if let Ok(header_value) = HeaderValue::from_str(&self.state.installation_id) { + extra_headers.insert(X_CODEX_INSTALLATION_ID_HEADER, header_value); + } + extra_headers.extend(self.build_responses_identity_headers()); extra_headers.extend(build_conversation_headers(Some( self.state.conversation_id.to_string(), ))); @@ -452,23 +497,60 @@ impl ModelClient { fn build_subagent_headers(&self) -> ApiHeaderMap { let mut extra_headers = ApiHeaderMap::new(); - if let SessionSource::SubAgent(sub) = &self.state.session_source { - let subagent = match sub { - crate::protocol::SubAgentSource::Review => "review".to_string(), - crate::protocol::SubAgentSource::Compact => "compact".to_string(), - crate::protocol::SubAgentSource::MemoryConsolidation => { - "memory_consolidation".to_string() - } - crate::protocol::SubAgentSource::ThreadSpawn { .. } => "collab_spawn".to_string(), - crate::protocol::SubAgentSource::Other(label) => label.clone(), - }; - if let Ok(val) = HeaderValue::from_str(&subagent) { - extra_headers.insert("x-openai-subagent", val); - } + if let Some(subagent) = subagent_header_value(&self.state.session_source) + && let Ok(val) = HeaderValue::from_str(&subagent) + { + extra_headers.insert(X_OPENAI_SUBAGENT_HEADER, val); } extra_headers } + fn build_responses_identity_headers(&self) -> ApiHeaderMap { + let mut extra_headers = self.build_subagent_headers(); + if let Some(parent_thread_id) = parent_thread_id_header_value(&self.state.session_source) + && let Ok(val) = HeaderValue::from_str(&parent_thread_id) + { + extra_headers.insert(X_CODEX_PARENT_THREAD_ID_HEADER, val); + } + if let Ok(val) = HeaderValue::from_str(&self.current_window_id()) { + extra_headers.insert(X_CODEX_WINDOW_ID_HEADER, val); + } + extra_headers + } + + fn build_ws_client_metadata( + &self, + turn_metadata_header: Option<&str>, + ) -> HashMap { + let mut client_metadata = HashMap::new(); + client_metadata.insert( + X_CODEX_INSTALLATION_ID_HEADER.to_string(), + self.state.installation_id.clone(), + ); + client_metadata.insert( + X_CODEX_WINDOW_ID_HEADER.to_string(), + self.current_window_id(), + ); + if let Some(subagent) = subagent_header_value(&self.state.session_source) { + client_metadata.insert(X_OPENAI_SUBAGENT_HEADER.to_string(), subagent); + } + if let Some(parent_thread_id) = parent_thread_id_header_value(&self.state.session_source) { + client_metadata.insert( + X_CODEX_PARENT_THREAD_ID_HEADER.to_string(), + parent_thread_id, + ); + } + if let Some(turn_metadata_header) = parse_turn_metadata_header(turn_metadata_header) + && let Ok(turn_metadata) = turn_metadata_header.to_str() + { + client_metadata.insert( + X_CODEX_TURN_METADATA_HEADER.to_string(), + turn_metadata.to_string(), + ); + } + client_metadata + } + /// Builds request telemetry for unary API calls (e.g., Compact endpoint). fn build_request_telemetry( session_telemetry: &SessionTelemetry, @@ -568,7 +650,7 @@ impl ModelClient { websocket_connect_timeout, ApiWebSocketResponsesClient::new(api_provider, api_auth).connect( headers, - crate::default_client::default_headers(), + codex_login::default_client::default_headers(), turn_state, Some(websocket_telemetry), ), @@ -648,6 +730,7 @@ impl ModelClient { headers.insert("x-client-request-id", header_value); } headers.extend(build_conversation_headers(Some(conversation_id))); + headers.extend(self.build_responses_identity_headers()); headers.insert( OPENAI_BETA_HEADER, HeaderValue::from_static(RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE), @@ -671,7 +754,7 @@ impl Drop for ModelClientSession { } impl ModelClientSession { - fn reset_websocket_session(&mut self) { + pub(crate) fn reset_websocket_session(&mut self) { self.websocket_session.connection = None; self.websocket_session.last_request = None; self.websocket_session.last_response_rx = None; @@ -688,7 +771,10 @@ impl ModelClientSession { summary: ReasoningSummaryConfig, service_tier: Option, ) -> Result { - let instructions = &prompt.base_instructions.text; + let instructions = prompt + .base_instructions + .as_ref() + .map(|base_instructions| base_instructions.text.clone()); let input = prompt.get_formatted_input(); let tools = create_tools_json_for_responses_api(&prompt.tools)?; let default_reasoning_effort = model_info.default_reasoning_level; @@ -727,7 +813,7 @@ impl ModelClientSession { let prompt_cache_key = Some(self.client.state.conversation_id.to_string()); let request = ResponsesApiRequest { model: model_info.slug.clone(), - instructions: instructions.clone(), + instructions, input, tools, tool_choice: "auto".to_string(), @@ -743,6 +829,10 @@ impl ModelClientSession { }, prompt_cache_key, text, + client_metadata: Some(HashMap::from([( + X_CODEX_INSTALLATION_ID_HEADER.to_string(), + self.client.state.installation_id.clone(), + )])), }; Ok(request) } @@ -762,11 +852,15 @@ impl ModelClientSession { ApiResponsesOptions { conversation_id: Some(conversation_id), session_source: Some(self.client.state.session_source.clone()), - extra_headers: build_responses_headers( - self.client.state.beta_features_header.as_deref(), - Some(&self.turn_state), - turn_metadata_header.as_ref(), - ), + extra_headers: { + let mut headers = build_responses_headers( + self.client.state.beta_features_header.as_deref(), + Some(&self.turn_state), + turn_metadata_header.as_ref(), + ); + headers.extend(self.client.build_responses_identity_headers()); + headers + }, compression, turn_state: Some(Arc::clone(&self.turn_state)), } @@ -966,7 +1060,7 @@ impl ModelClientSession { )) } - fn responses_request_compression(&self, auth: Option<&crate::auth::CodexAuth>) -> Compression { + fn responses_request_compression(&self, auth: Option<&CodexAuth>) -> Compression { if self.client.state.enable_request_compression && auth.is_some_and(CodexAuth::is_chatgpt_auth) && self.client.state.provider.is_openai() @@ -1019,7 +1113,7 @@ impl ModelClientSession { let auth_manager = self.client.state.auth_manager.clone(); let mut auth_recovery = auth_manager .as_ref() - .map(super::auth::AuthManager::unauthorized_recovery); + .map(AuthManager::unauthorized_recovery); let mut pending_retry = PendingUnauthorizedRetry::default(); loop { let client_setup = self.client.current_client_setup().await?; @@ -1108,7 +1202,7 @@ impl ModelClientSession { let mut auth_recovery = auth_manager .as_ref() - .map(super::auth::AuthManager::unauthorized_recovery); + .map(AuthManager::unauthorized_recovery); let mut pending_retry = PendingUnauthorizedRetry::default(); loop { let client_setup = self.client.current_client_setup().await?; @@ -1130,7 +1224,7 @@ impl ModelClientSession { )?; let mut ws_payload = ResponseCreateWsRequest { client_metadata: response_create_client_metadata( - build_ws_client_metadata(turn_metadata_header), + Some(self.client.build_ws_client_metadata(turn_metadata_header)), request_trace.as_ref(), ), ..ResponseCreateWsRequest::from(&request) @@ -1363,14 +1457,6 @@ fn parse_turn_metadata_header(turn_metadata_header: Option<&str>) -> Option) -> Option> { - let turn_metadata_header = parse_turn_metadata_header(turn_metadata_header)?; - let turn_metadata = turn_metadata_header.to_str().ok()?.to_string(); - let mut client_metadata = HashMap::new(); - client_metadata.insert(X_CODEX_TURN_METADATA_HEADER.to_string(), turn_metadata); - Some(client_metadata) -} - /// Builds the extra headers attached to Responses API requests. /// /// These headers implement Codex-specific conventions: @@ -1402,6 +1488,34 @@ fn build_responses_headers( headers } +fn subagent_header_value(session_source: &SessionSource) -> Option { + let SessionSource::SubAgent(subagent_source) = session_source else { + return None; + }; + match subagent_source { + SubAgentSource::Review => Some("review".to_string()), + SubAgentSource::Compact => Some("compact".to_string()), + SubAgentSource::MemoryConsolidation => Some("memory_consolidation".to_string()), + SubAgentSource::ThreadSpawn { .. } => Some("collab_spawn".to_string()), + SubAgentSource::Other(label) => Some(label.clone()), + } +} + +fn parent_thread_id_header_value(session_source: &SessionSource) -> Option { + match session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, .. + }) => Some(parent_thread_id.to_string()), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::SubAgent(_) + | SessionSource::Unknown => None, + } +} + fn map_response_stream( api_stream: S, session_telemetry: SessionTelemetry, diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 33a1f535c6..a90d75bf01 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -1,10 +1,10 @@ -use crate::client_common::tools::ToolSpec; -use crate::config::types::Personality; -use crate::error::Result; -pub use codex_api::common::ResponseEvent; +pub use codex_api::ResponseEvent; +use codex_config::types::Personality; +use codex_protocol::error::Result; use codex_protocol::models::BaseInstructions; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::ResponseItem; +use codex_tools::ToolSpec; use futures::Stream; use serde::Deserialize; use serde_json::Value; @@ -23,7 +23,7 @@ pub const REVIEW_EXIT_INTERRUPTED_TMPL: &str = include_str!("../templates/review/exit_interrupted.xml"); /// API request payload for a single model turn -#[derive(Default, Debug, Clone)] +#[derive(Debug, Clone)] pub struct Prompt { /// Conversation context input items. pub input: Vec, @@ -35,7 +35,7 @@ pub struct Prompt { /// Whether parallel tool calls are permitted for this prompt. pub(crate) parallel_tool_calls: bool, - pub base_instructions: BaseInstructions, + pub base_instructions: Option, /// Optionally specify the personality of the model. pub personality: Option, @@ -44,6 +44,19 @@ pub struct Prompt { pub output_schema: Option, } +impl Default for Prompt { + fn default() -> Self { + Self { + input: Vec::new(), + tools: Vec::new(), + parallel_tool_calls: false, + base_instructions: Some(BaseInstructions::default()), + personality: None, + output_schema: None, + } + } +} + impl Prompt { pub(crate) fn get_formatted_input(&self) -> Vec { let mut input = self.input.clone(); @@ -156,161 +169,6 @@ fn strip_total_output_header(output: &str) -> Option<(&str, u32)> { Some((remainder, total_lines)) } -pub(crate) mod tools { - use crate::tools::spec::JsonSchema; - use codex_protocol::config_types::WebSearchContextSize; - use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters; - use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation; - use codex_protocol::config_types::WebSearchUserLocationType; - use serde::Deserialize; - use serde::Serialize; - use serde_json::Value; - - /// When serialized as JSON, this produces a valid "Tool" in the OpenAI - /// Responses API. - #[derive(Debug, Clone, Serialize, PartialEq)] - #[serde(tag = "type")] - pub(crate) enum ToolSpec { - #[serde(rename = "function")] - Function(ResponsesApiTool), - #[serde(rename = "tool_search")] - ToolSearch { - execution: String, - description: String, - parameters: JsonSchema, - }, - #[serde(rename = "local_shell")] - LocalShell {}, - #[serde(rename = "image_generation")] - ImageGeneration { output_format: String }, - // TODO: Understand why we get an error on web_search although the API docs say it's supported. - // https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses#:~:text=%7B%20type%3A%20%22web_search%22%20%7D%2C - // The `external_web_access` field determines whether the web search is over cached or live content. - // https://platform.openai.com/docs/guides/tools-web-search#live-internet-access - #[serde(rename = "web_search")] - WebSearch { - #[serde(skip_serializing_if = "Option::is_none")] - external_web_access: Option, - #[serde(skip_serializing_if = "Option::is_none")] - filters: Option, - #[serde(skip_serializing_if = "Option::is_none")] - user_location: Option, - #[serde(skip_serializing_if = "Option::is_none")] - search_context_size: Option, - #[serde(skip_serializing_if = "Option::is_none")] - search_content_types: Option>, - }, - #[serde(rename = "custom")] - Freeform(FreeformTool), - } - - impl ToolSpec { - pub(crate) fn name(&self) -> &str { - match self { - ToolSpec::Function(tool) => tool.name.as_str(), - ToolSpec::ToolSearch { .. } => "tool_search", - ToolSpec::LocalShell {} => "local_shell", - ToolSpec::ImageGeneration { .. } => "image_generation", - ToolSpec::WebSearch { .. } => "web_search", - ToolSpec::Freeform(tool) => tool.name.as_str(), - } - } - } - - #[derive(Debug, Clone, Serialize, PartialEq)] - pub(crate) struct ResponsesApiWebSearchFilters { - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) allowed_domains: Option>, - } - - impl From for ResponsesApiWebSearchFilters { - fn from(filters: ConfigWebSearchFilters) -> Self { - Self { - allowed_domains: filters.allowed_domains, - } - } - } - - #[derive(Debug, Clone, Serialize, PartialEq)] - pub(crate) struct ResponsesApiWebSearchUserLocation { - #[serde(rename = "type")] - pub(crate) r#type: WebSearchUserLocationType, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) country: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) region: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) city: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) timezone: Option, - } - - impl From for ResponsesApiWebSearchUserLocation { - fn from(user_location: ConfigWebSearchUserLocation) -> Self { - Self { - r#type: user_location.r#type, - country: user_location.country, - region: user_location.region, - city: user_location.city, - timezone: user_location.timezone, - } - } - } - - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] - pub struct FreeformTool { - pub(crate) name: String, - pub(crate) description: String, - pub(crate) format: FreeformToolFormat, - } - - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] - pub struct FreeformToolFormat { - pub(crate) r#type: String, - pub(crate) syntax: String, - pub(crate) definition: String, - } - - #[derive(Debug, Clone, Serialize, PartialEq)] - pub struct ResponsesApiTool { - pub(crate) name: String, - pub(crate) description: String, - /// TODO: Validation. When strict is set to true, the JSON schema, - /// `required` and `additional_properties` must be present. All fields in - /// `properties` must be present in `required`. - pub(crate) strict: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) defer_loading: Option, - pub(crate) parameters: JsonSchema, - #[serde(skip)] - pub(crate) output_schema: Option, - } - - #[derive(Debug, Clone, Serialize, PartialEq)] - #[serde(tag = "type")] - pub(crate) enum ToolSearchOutputTool { - #[allow(dead_code)] - #[serde(rename = "function")] - Function(ResponsesApiTool), - #[serde(rename = "namespace")] - Namespace(ResponsesApiNamespace), - } - - #[derive(Debug, Clone, Serialize, PartialEq)] - pub(crate) struct ResponsesApiNamespace { - pub(crate) name: String, - pub(crate) description: String, - pub(crate) tools: Vec, - } - - #[derive(Debug, Clone, Serialize, PartialEq)] - #[serde(tag = "type")] - pub(crate) enum ResponsesApiNamespaceTool { - #[serde(rename = "function")] - Function(ResponsesApiTool), - } -} - pub struct ResponseStream { pub(crate) rx_event: mpsc::Receiver>, } diff --git a/codex-rs/core/src/client_common_tests.rs b/codex-rs/core/src/client_common_tests.rs index 2f2305c7ab..4d4b5af4ff 100644 --- a/codex-rs/core/src/client_common_tests.rs +++ b/codex-rs/core/src/client_common_tests.rs @@ -1,6 +1,6 @@ +use codex_api::OpenAiVerbosity; use codex_api::ResponsesApiRequest; -use codex_api::common::OpenAiVerbosity; -use codex_api::common::TextControls; +use codex_api::TextControls; use codex_api::create_text_param_for_request; use codex_protocol::config_types::ServiceTier; use codex_protocol::models::FunctionCallOutputPayload; @@ -14,7 +14,7 @@ fn serializes_text_verbosity_when_set() { let tools: Vec = vec![]; let req = ResponsesApiRequest { model: "gpt-5.1".to_string(), - instructions: "i".to_string(), + instructions: Some("i".to_string()), input, tools, tool_choice: "auto".to_string(), @@ -29,6 +29,7 @@ fn serializes_text_verbosity_when_set() { verbosity: Some(OpenAiVerbosity::Low), format: None, }), + client_metadata: None, }; let v = serde_json::to_value(&req).expect("json"); @@ -52,11 +53,12 @@ fn serializes_text_schema_with_strict_format() { "required": ["answer"], }); let text_controls = - create_text_param_for_request(None, &Some(schema.clone())).expect("text controls"); + create_text_param_for_request(/*verbosity*/ None, &Some(schema.clone())) + .expect("text controls"); let req = ResponsesApiRequest { model: "gpt-5.1".to_string(), - instructions: "i".to_string(), + instructions: Some("i".to_string()), input, tools, tool_choice: "auto".to_string(), @@ -68,6 +70,7 @@ fn serializes_text_schema_with_strict_format() { prompt_cache_key: None, service_tier: None, text: Some(text_controls), + client_metadata: None, }; let v = serde_json::to_value(&req).expect("json"); @@ -93,7 +96,7 @@ fn omits_text_when_not_set() { let tools: Vec = vec![]; let req = ResponsesApiRequest { model: "gpt-5.1".to_string(), - instructions: "i".to_string(), + instructions: Some("i".to_string()), input, tools, tool_choice: "auto".to_string(), @@ -105,6 +108,7 @@ fn omits_text_when_not_set() { prompt_cache_key: None, service_tier: None, text: None, + client_metadata: None, }; let v = serde_json::to_value(&req).expect("json"); @@ -115,7 +119,7 @@ fn omits_text_when_not_set() { fn serializes_flex_service_tier_when_set() { let req = ResponsesApiRequest { model: "gpt-5.1".to_string(), - instructions: "i".to_string(), + instructions: Some("i".to_string()), input: vec![], tools: vec![], tool_choice: "auto".to_string(), @@ -127,6 +131,7 @@ fn serializes_flex_service_tier_when_set() { prompt_cache_key: None, service_tier: Some(ServiceTier::Flex.to_string()), text: None, + client_metadata: None, }; let v = serde_json::to_value(&req).expect("json"); @@ -197,49 +202,3 @@ fn reserializes_shell_outputs_for_function_and_custom_tool_calls() { ] ); } - -#[test] -fn tool_search_output_namespace_serializes_with_deferred_child_tools() { - let namespace = tools::ToolSearchOutputTool::Namespace(tools::ResponsesApiNamespace { - name: "mcp__codex_apps__calendar".to_string(), - description: "Plan events".to_string(), - tools: vec![tools::ResponsesApiNamespaceTool::Function( - tools::ResponsesApiTool { - name: "create_event".to_string(), - description: "Create a calendar event.".to_string(), - strict: false, - defer_loading: Some(true), - parameters: crate::tools::spec::JsonSchema::Object { - properties: Default::default(), - required: None, - additional_properties: None, - }, - output_schema: None, - }, - )], - }); - - let value = serde_json::to_value(namespace).expect("serialize namespace"); - - assert_eq!( - value, - serde_json::json!({ - "type": "namespace", - "name": "mcp__codex_apps__calendar", - "description": "Plan events", - "tools": [ - { - "type": "function", - "name": "create_event", - "description": "Create a calendar event.", - "strict": false, - "defer_loading": true, - "parameters": { - "type": "object", - "properties": {} - } - } - ] - }) - ); -} diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 2c07b4fd1d..2fd5f04f95 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -2,6 +2,15 @@ use super::AuthRequestTelemetryContext; use super::ModelClient; use super::PendingUnauthorizedRetry; use super::UnauthorizedRecoveryExecution; +use super::X_CODEX_INSTALLATION_ID_HEADER; +use super::X_CODEX_PARENT_THREAD_ID_HEADER; +use super::X_CODEX_TURN_METADATA_HEADER; +use super::X_CODEX_WINDOW_ID_HEADER; +use super::X_OPENAI_SUBAGENT_HEADER; +use codex_api::CoreAuthProvider; +use codex_app_server_protocol::AuthMode; +use codex_model_provider_info::WireApi; +use codex_model_provider_info::create_oss_provider_with_base_url; use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelInfo; @@ -11,19 +20,17 @@ use pretty_assertions::assert_eq; use serde_json::json; fn test_model_client(session_source: SessionSource) -> ModelClient { - let provider = crate::model_provider_info::create_oss_provider_with_base_url( - "https://example.com/v1", - crate::model_provider_info::WireApi::Responses, - ); + let provider = create_oss_provider_with_base_url("https://example.com/v1", WireApi::Responses); ModelClient::new( - None, + /*auth_manager*/ None, ThreadId::new(), + /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), provider, session_source, - None, - false, - false, - None, + /*model_verbosity*/ None, + /*enable_request_compression*/ false, + /*include_timing_metrics*/ false, + /*beta_features_header*/ None, ) } @@ -62,11 +69,11 @@ fn test_session_telemetry() -> SessionTelemetry { ThreadId::new(), "gpt-test", "gpt-test", - None, - None, - None, + /*account_id*/ None, + /*account_email*/ None, + /*auth_mode*/ None, "test-originator".to_string(), - false, + /*log_user_prompts*/ false, "test-terminal".to_string(), SessionSource::Cli, ) @@ -79,11 +86,53 @@ fn build_subagent_headers_sets_other_subagent_label() { ))); let headers = client.build_subagent_headers(); let value = headers - .get("x-openai-subagent") + .get(X_OPENAI_SUBAGENT_HEADER) .and_then(|value| value.to_str().ok()); assert_eq!(value, Some("memory_consolidation")); } +#[test] +fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() { + let parent_thread_id = ThreadId::new(); + let client = test_model_client(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 2, + agent_path: None, + agent_nickname: None, + agent_role: None, + })); + + client.advance_window_generation(); + + let client_metadata = client.build_ws_client_metadata(Some(r#"{"turn_id":"turn-123"}"#)); + let conversation_id = client.state.conversation_id; + assert_eq!( + client_metadata, + std::collections::HashMap::from([ + ( + X_CODEX_INSTALLATION_ID_HEADER.to_string(), + "11111111-1111-4111-8111-111111111111".to_string(), + ), + ( + X_CODEX_WINDOW_ID_HEADER.to_string(), + format!("{conversation_id}:1"), + ), + ( + X_OPENAI_SUBAGENT_HEADER.to_string(), + "collab_spawn".to_string(), + ), + ( + X_CODEX_PARENT_THREAD_ID_HEADER.to_string(), + parent_thread_id.to_string(), + ), + ( + X_CODEX_TURN_METADATA_HEADER.to_string(), + r#"{"turn_id":"turn-123"}"#.to_string(), + ), + ]) + ); +} + #[tokio::test] async fn summarize_memories_returns_empty_for_empty_input() { let client = test_model_client(SessionSource::Cli); @@ -91,7 +140,12 @@ async fn summarize_memories_returns_empty_for_empty_input() { let session_telemetry = test_session_telemetry(); let output = client - .summarize_memories(Vec::new(), &model_info, None, &session_telemetry) + .summarize_memories( + Vec::new(), + &model_info, + /*effort*/ None, + &session_telemetry, + ) .await .expect("empty summarize request should succeed"); assert_eq!(output.len(), 0); @@ -100,8 +154,8 @@ async fn summarize_memories_returns_empty_for_empty_input() { #[test] fn auth_request_telemetry_context_tracks_attached_auth_and_retry_phase() { let auth_context = AuthRequestTelemetryContext::new( - Some(crate::auth::AuthMode::Chatgpt), - &crate::api_bridge::CoreAuthProvider::for_test(Some("access-token"), Some("workspace-123")), + Some(AuthMode::Chatgpt), + &CoreAuthProvider::for_test(Some("access-token"), Some("workspace-123")), PendingUnauthorizedRetry::from_recovery(UnauthorizedRecoveryExecution { mode: "managed", phase: "refresh_token", diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ecc9d3dada..dce76bf3d2 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -5,15 +5,16 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicU64; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; -use crate::AuthManager; -use crate::CodexAuth; -use crate::SandboxState; use crate::agent::AgentControl; use crate::agent::AgentStatus; +use crate::agent::Mailbox; +use crate::agent::MailboxReceiver; use crate::agent::agent_status_from_event; +use crate::agent::status::is_final; use crate::apps::render_apps_section; -use crate::auth_env_telemetry::collect_auth_env_telemetry; use crate::commit_attribution::commit_message_trailer_instruction; use crate::compact; use crate::compact::InitialContextInjection; @@ -23,11 +24,7 @@ use crate::compact_remote::run_inline_remote_auto_compact_task; use crate::config::ManagedFeatures; use crate::connectors; use crate::exec_policy::ExecPolicyManager; -#[cfg(test)] -use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig; -use crate::models_manager::manager::ModelsManager; -use crate::models_manager::manager::RefreshStrategy; -use crate::parse_command::parse_command; +use crate::installation_id::resolve_installation_id; use crate::parse_turn_item; use crate::path_utils::normalize_for_native_workdir; use crate::realtime_conversation::RealtimeConversationManager; @@ -37,6 +34,7 @@ use crate::realtime_conversation::handle_start as handle_realtime_conversation_s use crate::realtime_conversation::handle_text as handle_realtime_conversation_text; use crate::render_skills_section; use crate::rollout::session_index; +use crate::session_prefix::format_subagent_notification_message; use crate::skills_load_input_from_config; use crate::stream_events_utils::HandleOutputCtx; use crate::stream_events_utils::handle_non_tool_response_item; @@ -53,10 +51,13 @@ use chrono::Utc; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; +use codex_analytics::SubAgentThreadStartedInput; use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; +use codex_config::types::OAuthCredentialsStoreMode; use codex_exec_server::Environment; +use codex_exec_server::EnvironmentManager; use codex_features::FEATURES; use codex_features::Feature; use codex_features::unstable_features_warning_event; @@ -66,6 +67,19 @@ use codex_hooks::HookPayload; use codex_hooks::HookResult; use codex_hooks::Hooks; use codex_hooks::HooksConfig; +use codex_login::AuthManager; +use codex_login::CodexAuth; +use codex_login::auth_env_telemetry::collect_auth_env_telemetry; +use codex_login::default_client::originator; +use codex_mcp::McpConnectionManager; +use codex_mcp::SandboxState; +use codex_mcp::ToolInfo as McpToolInfo; +use codex_mcp::codex_apps_tools_cache_key; +use codex_mcp::filter_non_codex_apps_mcp_tools_only; +#[cfg(test)] +use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig; +use codex_models_manager::manager::ModelsManager; +use codex_models_manager::manager::RefreshStrategy; use codex_network_proxy::NetworkProxy; use codex_network_proxy::NetworkProxyAuditMetadata; use codex_network_proxy::normalize_host; @@ -74,7 +88,6 @@ use codex_otel::current_span_w3c_trace_context; use codex_otel::set_parent_from_w3c_trace_context; use codex_protocol::ThreadId; use codex_protocol::approvals::ElicitationRequestEvent; -use codex_protocol::approvals::ExecApprovalRequestSkillMetadata; use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::approvals::NetworkPolicyAmendment; use codex_protocol::approvals::NetworkPolicyRuleAction; @@ -97,6 +110,7 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::HasLegacyEvent; +use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::ItemStartedEvent; use codex_protocol::protocol::RawResponseItemEvent; @@ -116,8 +130,10 @@ use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputArgs; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationResponse; -use codex_rmcp_client::OAuthCredentialsStoreMode; +use codex_rollout::state_db; +use codex_shell_command::parse_command::parse_command; use codex_terminal_detection::user_agent; +use codex_tools::filter_tool_suggest_discoverable_tools_for_client; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_stream_parser::AssistantTextChunk; use codex_utils_stream_parser::AssistantTextStreamParser; @@ -134,7 +150,6 @@ use rmcp::model::PaginatedRequestParams; use rmcp::model::ReadResourceRequestParams; use rmcp::model::ReadResourceResult; use rmcp::model::RequestId; -use serde_json; use serde_json::Value; use tokio::sync::Mutex; use tokio::sync::RwLock; @@ -156,7 +171,6 @@ use tracing::trace_span; use tracing::warn; use uuid::Uuid; -use crate::ModelProviderInfo; use crate::client::ModelClient; use crate::client::ModelClientSession; use crate::client_common::Prompt; @@ -169,16 +183,17 @@ use crate::config::ConstraintResult; use crate::config::GhostSnapshotConfig; use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; -use crate::config::types::McpServerConfig; -use crate::config::types::ShellEnvironmentPolicy; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::environment_context::EnvironmentContext; -use crate::error::CodexErr; -use crate::error::Result as CodexResult; -#[cfg(test)] -use crate::exec::StreamOutput; use codex_config::CONFIG_TOML_FILE; +use codex_config::types::McpServerConfig; +use codex_config::types::ShellEnvironmentPolicy; +use codex_model_provider_info::ModelProviderInfo; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result as CodexResult; +#[cfg(test)] +use codex_protocol::exec_output::StreamOutput; mod rollout_reconstruction; #[cfg(test)] @@ -256,14 +271,8 @@ use crate::injection::ToolMentionKind; use crate::injection::app_id_from_path; use crate::injection::tool_kind_for_path; use crate::instructions::UserInstructions; -use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::McpManager; -use crate::mcp::auth::compute_auth_statuses; -use crate::mcp::maybe_prompt_and_install_mcp_dependencies; -use crate::mcp::with_codex_apps_mcp; -use crate::mcp_connection_manager::McpConnectionManager; -use crate::mcp_connection_manager::codex_apps_tools_cache_key; -use crate::mcp_connection_manager::filter_non_codex_apps_mcp_tools_only; +use crate::mcp_skill_dependencies::maybe_prompt_and_install_mcp_dependencies; use crate::memories; use crate::mentions::build_connector_slug_counts; use crate::mentions::build_skill_name_counts; @@ -275,43 +284,6 @@ use crate::plugins::PluginsManager; use crate::plugins::build_plugin_injections; use crate::plugins::render_plugins_section; use crate::project_doc::get_user_instructions; -use crate::protocol::AgentMessageContentDeltaEvent; -use crate::protocol::AgentReasoningSectionBreakEvent; -use crate::protocol::ApplyPatchApprovalRequestEvent; -use crate::protocol::AskForApproval; -use crate::protocol::BackgroundEventEvent; -use crate::protocol::CompactedItem; -use crate::protocol::DeprecationNoticeEvent; -use crate::protocol::ErrorEvent; -use crate::protocol::Event; -use crate::protocol::EventMsg; -use crate::protocol::ExecApprovalRequestEvent; -use crate::protocol::McpServerRefreshConfig; -use crate::protocol::ModelRerouteEvent; -use crate::protocol::ModelRerouteReason; -use crate::protocol::NetworkApprovalContext; -use crate::protocol::Op; -use crate::protocol::PlanDeltaEvent; -use crate::protocol::RateLimitSnapshot; -use crate::protocol::ReasoningContentDeltaEvent; -use crate::protocol::ReasoningRawContentDeltaEvent; -use crate::protocol::RequestUserInputEvent; -use crate::protocol::ReviewDecision; -use crate::protocol::SandboxPolicy; -use crate::protocol::SessionConfiguredEvent; -use crate::protocol::SessionNetworkProxyRuntime; -use crate::protocol::SkillDependencies as ProtocolSkillDependencies; -use crate::protocol::SkillErrorInfo; -use crate::protocol::SkillInterface as ProtocolSkillInterface; -use crate::protocol::SkillMetadata as ProtocolSkillMetadata; -use crate::protocol::SkillToolDependency as ProtocolSkillToolDependency; -use crate::protocol::StreamErrorEvent; -use crate::protocol::Submission; -use crate::protocol::TokenCountEvent; -use crate::protocol::TokenUsage; -use crate::protocol::TokenUsageInfo; -use crate::protocol::TurnDiffEvent; -use crate::protocol::WarningEvent; use crate::resolve_skill_dependencies_for_turn; use crate::rollout::RolloutRecorder; use crate::rollout::RolloutRecorderParams; @@ -324,9 +296,9 @@ use crate::shell_snapshot::ShellSnapshot; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::state::ActiveTurn; +use crate::state::MailboxDeliveryPhase; use crate::state::SessionServices; use crate::state::SessionState; -use crate::state_db; use crate::tasks::GhostSnapshotTask; use crate::tasks::ReviewTask; use crate::tasks::SessionTask; @@ -341,8 +313,6 @@ use crate::tools::network_approval::build_network_policy_decider; use crate::tools::parallel::ToolCallRuntime; use crate::tools::router::ToolRouterParams; use crate::tools::sandboxing::ApprovalStore; -use crate::tools::spec::ToolsConfig; -use crate::tools::spec::ToolsConfigParams; use crate::turn_diff_tracker::TurnDiffTracker; use crate::turn_timing::TurnTimingState; use crate::turn_timing::record_turn_ttfm_metric; @@ -352,9 +322,12 @@ use crate::util::backoff; use crate::windows_sandbox::WindowsSandboxLevelExt; use codex_async_utils::OrCancelExt; use codex_git_utils::get_git_repo_root; +use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_mcp::compute_auth_statuses; +use codex_mcp::with_codex_apps_mcp; use codex_otel::SessionTelemetry; +use codex_otel::THREAD_STARTED_METRIC; use codex_otel::TelemetryAuthMode; -use codex_otel::metrics::names::THREAD_STARTED_METRIC; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; @@ -362,13 +335,53 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::ContentItem; use codex_protocol::models::DeveloperInstructions; +use codex_protocol::models::MessagePhase; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::protocol::AgentMessageContentDeltaEvent; +use codex_protocol::protocol::AgentReasoningSectionBreakEvent; +use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::BackgroundEventEvent; use codex_protocol::protocol::CodexErrorInfo; +use codex_protocol::protocol::CompactedItem; +use codex_protocol::protocol::DeprecationNoticeEvent; +use codex_protocol::protocol::ErrorEvent; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecApprovalRequestEvent; use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::McpServerRefreshConfig; +use codex_protocol::protocol::ModelRerouteEvent; +use codex_protocol::protocol::ModelRerouteReason; +use codex_protocol::protocol::NetworkApprovalContext; use codex_protocol::protocol::NonSteerableTurnKind; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::PlanDeltaEvent; +use codex_protocol::protocol::RateLimitSnapshot; +use codex_protocol::protocol::ReasoningContentDeltaEvent; +use codex_protocol::protocol::ReasoningRawContentDeltaEvent; +use codex_protocol::protocol::RequestUserInputEvent; +use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionConfiguredEvent; +use codex_protocol::protocol::SessionNetworkProxyRuntime; +use codex_protocol::protocol::SkillDependencies as ProtocolSkillDependencies; +use codex_protocol::protocol::SkillErrorInfo; +use codex_protocol::protocol::SkillInterface as ProtocolSkillInterface; +use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; +use codex_protocol::protocol::SkillToolDependency as ProtocolSkillToolDependency; +use codex_protocol::protocol::StreamErrorEvent; +use codex_protocol::protocol::Submission; +use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TokenUsageInfo; +use codex_protocol::protocol::TurnDiffEvent; +use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; +use codex_tools::ToolsConfig; +use codex_tools::ToolsConfigParams; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_readiness::Readiness; use codex_utils_readiness::ReadinessFlag; @@ -394,14 +407,13 @@ pub(crate) type SessionLoopTermination = Shared>; pub struct CodexSpawnOk { pub codex: Codex, pub thread_id: ThreadId, - #[deprecated(note = "use thread_id")] - pub conversation_id: ThreadId, } pub(crate) struct CodexSpawnArgs { pub(crate) config: Config, pub(crate) auth_manager: Arc, pub(crate) models_manager: Arc, + pub(crate) environment_manager: Arc, pub(crate) skills_manager: Arc, pub(crate) plugins_manager: Arc, pub(crate) mcp_manager: Arc, @@ -455,6 +467,7 @@ impl Codex { mut config, auth_manager, models_manager, + environment_manager, skills_manager, plugins_manager, mcp_manager, @@ -521,7 +534,11 @@ impl Codex { config.startup_warnings.push(message); } - let user_instructions = get_user_instructions(&config).await; + let environment = environment_manager + .current() + .await + .map_err(|err| CodexErr::Fatal(format!("failed to create environment: {err}")))?; + let user_instructions = get_user_instructions(&config, environment.as_deref()).await; let exec_policy = if crate::guardian::is_guardian_reviewer_source(&session_source) { // Guardian review should rely on the built-in shell safety checks, @@ -540,13 +557,13 @@ impl Codex { let config = Arc::new(config); let refresh_strategy = match session_source { - SessionSource::SubAgent(_) => crate::models_manager::manager::RefreshStrategy::Offline, - _ => crate::models_manager::manager::RefreshStrategy::OnlineIfUncached, + SessionSource::SubAgent(_) => codex_models_manager::manager::RefreshStrategy::Offline, + _ => codex_models_manager::manager::RefreshStrategy::OnlineIfUncached, }; if config.model.is_none() || !matches!( refresh_strategy, - crate::models_manager::manager::RefreshStrategy::Offline + codex_models_manager::manager::RefreshStrategy::Offline ) { let _ = models_manager.list_models(refresh_strategy).await; @@ -559,12 +576,18 @@ impl Codex { // 1. config.base_instructions override // 2. conversation history => session_meta.base_instructions // 3. base_instructions for current model - let model_info = models_manager.get_model_info(model.as_str(), &config).await; - let base_instructions = config - .base_instructions - .clone() - .or_else(|| conversation_history.get_base_instructions().map(|s| s.text)) - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)); + let model_info = models_manager + .get_model_info(model.as_str(), &config.to_models_manager_config()) + .await; + let base_instructions = match config.base_instructions.clone() { + Some(base_instructions) => base_instructions, + None => conversation_history + .get_base_instructions() + .map(|base_instructions| { + base_instructions.map(|base_instructions| base_instructions.text) + }) + .unwrap_or_else(|| Some(model_info.get_model_instructions(config.personality))), + }; // Respect thread-start tools. When missing (resumed/forked threads), read from the db // first, then fall back to rollout-file tools. @@ -593,6 +616,14 @@ impl Codex { dynamic_tools }; + let developer_instructions_override = config + .developer_instructions_override + .clone() + .or_else(|| conversation_history.get_developer_instructions()); + let developer_instructions = developer_instructions_override + .clone() + .unwrap_or_else(|| config.developer_instructions.clone()); + // TODO (aibrahim): Consolidate config.model and config.model_reasoning_effort into config.collaboration_mode // to avoid extracting these fields separately and constructing CollaborationMode here. let collaboration_mode = CollaborationMode { @@ -608,7 +639,8 @@ impl Codex { collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, service_tier: config.service_tier, - developer_instructions: config.developer_instructions.clone(), + developer_instructions, + developer_instructions_override, user_instructions, personality: config.personality, base_instructions, @@ -625,6 +657,7 @@ impl Codex { original_config_do_not_use: Arc::clone(&config), metrics_service_name, app_server_client_name: None, + app_server_client_version: None, session_source, dynamic_tools, persist_extended_history, @@ -651,6 +684,7 @@ impl Codex { mcp_manager.clone(), skills_watcher, agent_control, + environment, ) .await .map_err(|e| { @@ -674,12 +708,7 @@ impl Codex { session_loop_termination: session_loop_termination_from_handle(session_loop_handle), }; - #[allow(deprecated)] - Ok(CodexSpawnOk { - codex, - thread_id, - conversation_id: thread_id, - }) + Ok(CodexSpawnOk { codex, thread_id }) } /// Submit the `op` wrapped in a `Submission` with a unique ID. @@ -743,13 +772,15 @@ impl Codex { self.session.steer_input(input, expected_turn_id).await } - pub(crate) async fn set_app_server_client_name( + pub(crate) async fn set_app_server_client_info( &self, app_server_client_name: Option, + app_server_client_version: Option, ) -> ConstraintResult<()> { self.session .update_settings(SessionSettingsUpdate { app_server_client_name, + app_server_client_version, ..Default::default() }) .await @@ -803,7 +834,9 @@ pub(crate) struct Session { pending_mcp_server_refresh_config: Mutex>, pub(crate) conversation: Arc, pub(crate) active_turn: Mutex>, - idle_pending_input: Mutex>, + mailbox: Mailbox, + mailbox_rx: Mutex, + idle_pending_input: Mutex>, // TODO (jif) merge with mailbox! pub(crate) guardian_review_session: GuardianReviewSessionManager, pub(crate) services: SessionServices, js_repl: Arc, @@ -839,7 +872,7 @@ pub(crate) struct TurnContext { pub(crate) reasoning_effort: Option, pub(crate) reasoning_summary: ReasoningSummaryConfig, pub(crate) session_source: SessionSource, - pub(crate) environment: Arc, + pub(crate) environment: Option>, /// The session's absolute working directory. All relative paths provided /// by the model as well as sandbox policies are resolved against this path /// instead of `std::env::current_dir()`. @@ -863,6 +896,7 @@ pub(crate) struct TurnContext { pub(crate) features: ManagedFeatures, pub(crate) ghost_snapshot: GhostSnapshotConfig, pub(crate) final_output_json_schema: Option, + pub(crate) codex_self_exe: Option, pub(crate) codex_linux_sandbox_exe: Option, pub(crate) tool_call_gate: Arc, pub(crate) truncation_policy: TruncationPolicy, @@ -881,14 +915,21 @@ impl TurnContext { } pub(crate) fn apps_enabled(&self) -> bool { - self.features - .apps_enabled_cached(self.auth_manager.as_deref()) + let is_chatgpt_auth = self + .auth_manager + .as_deref() + .and_then(AuthManager::auth_cached) + .as_ref() + .is_some_and(CodexAuth::is_chatgpt_auth); + self.features.apps_enabled_for_auth(is_chatgpt_auth) } pub(crate) async fn with_model(&self, model: String, models_manager: &ModelsManager) -> Self { let mut config = (*self.config).clone(); config.model = Some(model.clone()); - let model_info = models_manager.get_model_info(model.as_str(), &config).await; + let model_info = models_manager + .get_model_info(model.as_str(), &config.to_models_manager_config()) + .await; let truncation_policy = model_info.truncation_policy.into(); let supported_reasoning_levels = model_info .supported_reasoning_levels @@ -932,7 +973,10 @@ impl TurnContext { .with_unified_exec_shell_mode(self.tools_config.unified_exec_shell_mode.clone()) .with_web_search_config(self.tools_config.web_search_config.clone()) .with_allow_login_shell(self.tools_config.allow_login_shell) - .with_agent_roles(config.agent_roles.clone()); + .with_has_environment(self.tools_config.has_environment) + .with_agent_type_description(crate::agent::role::spawn_tool_spec::build( + &config.agent_roles, + )); Self { sub_id: self.sub_id.clone(), @@ -949,7 +993,7 @@ impl TurnContext { reasoning_effort, reasoning_summary: self.reasoning_summary, session_source: self.session_source.clone(), - environment: Arc::clone(&self.environment), + environment: self.environment.clone(), cwd: self.cwd.clone(), current_date: self.current_date.clone(), timezone: self.timezone.clone(), @@ -970,6 +1014,7 @@ impl TurnContext { features, ghost_snapshot: self.ghost_snapshot.clone(), final_output_json_schema: self.final_output_json_schema.clone(), + codex_self_exe: self.codex_self_exe.clone(), codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), tool_call_gate: Arc::new(ReadinessFlag::new()), truncation_policy, @@ -1024,8 +1069,16 @@ impl TurnContext { .network .as_ref()?; Some(TurnContextNetworkItem { - allowed_domains: network.allowed_domains.clone().unwrap_or_default(), - denied_domains: network.denied_domains.clone().unwrap_or_default(), + allowed_domains: network + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains) + .unwrap_or_default(), + denied_domains: network + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::denied_domains) + .unwrap_or_default(), }) } } @@ -1052,6 +1105,10 @@ pub(crate) struct SessionConfiguration { /// Developer instructions that supplement the base instructions. developer_instructions: Option, + /// Explicit developer instructions override, preserving `null` as distinct + /// from a missing override. + developer_instructions_override: Option>, + /// Model instructions that are appended to the base instructions. user_instructions: Option, @@ -1059,7 +1116,7 @@ pub(crate) struct SessionConfiguration { personality: Option, /// Base instructions for the session. - base_instructions: String, + base_instructions: Option, /// Compact prompt override. compact_prompt: Option, @@ -1088,6 +1145,7 @@ pub(crate) struct SessionConfiguration { /// Optional service name tag for session metrics. metrics_service_name: Option, app_server_client_name: Option, + app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, dynamic_tools: Vec, @@ -1181,6 +1239,9 @@ impl SessionConfiguration { if let Some(app_server_client_name) = updates.app_server_client_name.clone() { next_configuration.app_server_client_name = Some(app_server_client_name); } + if let Some(app_server_client_version) = updates.app_server_client_version.clone() { + next_configuration.app_server_client_version = Some(app_server_client_version); + } Ok(next_configuration) } } @@ -1198,9 +1259,26 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) final_output_json_schema: Option>, pub(crate) personality: Option, pub(crate) app_server_client_name: Option, + pub(crate) app_server_client_version: Option, +} + +pub(crate) struct AppServerClientMetadata { + pub(crate) client_name: Option, + pub(crate) client_version: Option, } impl Session { + pub(crate) async fn app_server_client_metadata(&self) -> AppServerClientMetadata { + let state = self.state.lock().await; + AppServerClientMetadata { + client_name: state.session_configuration.app_server_client_name.clone(), + client_version: state + .session_configuration + .app_server_client_version + .clone(), + } + } + /// Builds the `x-codex-beta-features` header value for this session. /// /// `ModelClient` is session-scoped and intentionally does not depend on the full `Config`, so @@ -1348,7 +1426,7 @@ impl Session { model_info: ModelInfo, models_manager: &ModelsManager, network: Option, - environment: Arc, + environment: Option>, sub_id: String, js_repl: Arc, skills_outcome: Arc, @@ -1375,13 +1453,16 @@ impl Session { windows_sandbox_level: session_configuration.windows_sandbox_level, }) .with_unified_exec_shell_mode_for_session( - user_shell, + crate::tools::spec::tool_user_shell_type(user_shell), shell_zsh_path, main_execve_wrapper_exe, ) .with_web_search_config(per_turn_config.web_search_config.clone()) .with_allow_login_shell(per_turn_config.permissions.allow_login_shell) - .with_agent_roles(per_turn_config.agent_roles.clone()); + .with_has_environment(environment.is_some()) + .with_agent_type_description(crate::agent::role::spawn_tool_spec::build( + &per_turn_config.agent_roles, + )); let cwd = session_configuration.cwd.clone(); @@ -1427,6 +1508,7 @@ impl Session { features: per_turn_config.features.clone(), ghost_snapshot: per_turn_config.ghost_snapshot.clone(), final_output_json_schema: None, + codex_self_exe: per_turn_config.codex_self_exe.clone(), codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(), tool_call_gate: Arc::new(ReadinessFlag::new()), truncation_policy: model_info.truncation_policy.into(), @@ -1455,6 +1537,7 @@ impl Session { mcp_manager: Arc, skills_watcher: Arc, agent_control: AgentControl, + environment: Option>, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -1472,9 +1555,13 @@ impl Session { conversation_id, forked_from_id, session_source, - BaseInstructions { - text: session_configuration.base_instructions.clone(), - }, + session_configuration + .base_instructions + .clone() + .map(|text| BaseInstructions { text }), + session_configuration + .developer_instructions_override + .clone(), session_configuration.dynamic_tools.clone(), if session_configuration.persist_extended_history { EventPersistenceMode::Extended @@ -1496,6 +1583,17 @@ impl Session { ), ), }; + let window_generation = match &initial_history { + InitialHistory::Resumed(resumed_history) => u64::try_from( + resumed_history + .history + .iter() + .filter(|item| matches!(item, RolloutItem::Compacted(_))) + .count(), + ) + .unwrap_or(u64::MAX), + InitialHistory::New | InitialHistory::Forked(_) => 0, + }; let state_builder = match &initial_history { InitialHistory::Resumed(resumed) => metadata::builder_from_items( resumed.history.as_slice(), @@ -1637,7 +1735,7 @@ impl Session { let auth_mode = auth.map(CodexAuth::auth_mode).map(TelemetryAuthMode::from); let account_id = auth.and_then(CodexAuth::get_account_id); let account_email = auth.and_then(CodexAuth::get_account_email); - let originator = crate::default_client::originator().value; + let originator = originator().value; let terminal_type = user_agent(); let session_model = session_configuration.collaboration_mode.model().to_string(); let auth_env_telemetry = collect_auth_env_telemetry( @@ -1830,6 +1928,7 @@ impl Session { }); } + let installation_id = resolve_installation_id(&config.codex_home).await?; let services = SessionServices { // Initialize the MCP connection manager with an uninitialized // instance. It will be replaced with one created via @@ -1862,7 +1961,6 @@ impl Session { session_telemetry, models_manager: Arc::clone(&models_manager), tool_approvals: Mutex::new(ApprovalStore::default()), - execve_session_approvals: RwLock::new(HashMap::new()), skills_manager, plugins_manager: Arc::clone(&plugins_manager), mcp_manager: Arc::clone(&mcp_manager), @@ -1874,6 +1972,7 @@ impl Session { model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), conversation_id, + installation_id, session_configuration.provider.clone(), session_configuration.session_source.clone(), config.model_verbosity, @@ -1884,10 +1983,11 @@ impl Session { code_mode_service: crate::tools::code_mode::CodeModeService::new( config.js_repl_node_path.clone(), ), - environment: Arc::new( - Environment::create(config.experimental_exec_server_url.clone()).await?, - ), + environment, }; + services + .model_client + .set_window_generation(window_generation); let js_repl = Arc::new(JsReplHandle::with_node_path( config.js_repl_node_path.clone(), config.js_repl_node_module_dirs.clone(), @@ -1895,6 +1995,7 @@ impl Session { let (out_of_band_elicitation_paused, _out_of_band_elicitation_paused_rx) = watch::channel(false); + let (mailbox, mailbox_rx) = Mailbox::new(); let sess = Arc::new(Session { conversation_id, tx_event: tx_event.clone(), @@ -1905,6 +2006,8 @@ impl Session { pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), + mailbox, + mailbox_rx: Mutex::new(mailbox_rx), idle_pending_input: Mutex::new(Vec::new()), guardian_review_session: GuardianReviewSessionManager::default(), services, @@ -1973,6 +2076,7 @@ impl Session { config.mcp_oauth_credentials_store_mode, auth_statuses.clone(), &session_configuration.approval_policy, + INITIAL_SUBMIT_ID.to_owned(), tx_event.clone(), sandbox_state, config.codex_home.clone(), @@ -2021,8 +2125,9 @@ impl Session { )); } } - sess.schedule_startup_prewarm(session_configuration.base_instructions.clone()) - .await; + if let Some(base_instructions) = session_configuration.base_instructions.clone() { + sess.schedule_startup_prewarm(base_instructions).await; + } let session_start_source = match &initial_history { InitialHistory::Resumed(_) => codex_hooks::SessionStartSource::Resume, InitialHistory::New | InitialHistory::Forked(_) => { @@ -2124,11 +2229,13 @@ impl Session { state.history.estimate_token_count(turn_context) } - pub(crate) async fn get_base_instructions(&self) -> BaseInstructions { + pub(crate) async fn get_base_instructions(&self) -> Option { let state = self.state.lock().await; - BaseInstructions { - text: state.session_configuration.base_instructions.clone(), - } + state + .session_configuration + .base_instructions + .clone() + .map(|text| BaseInstructions { text }) } // Merges connector IDs into the session-level explicit connector selection. @@ -2435,7 +2542,7 @@ impl Session { .models_manager .get_model_info( session_configuration.collaboration_mode.model(), - &per_turn_config, + &per_turn_config.to_models_manager_config(), ) .await; let plugin_outcome = self @@ -2465,7 +2572,7 @@ impl Session { .network_proxy .as_ref() .map(StartedNetworkProxy::proxy), - Arc::clone(&self.services.environment), + self.services.environment.clone(), sub_id, Arc::clone(&self.js_repl), skills_outcome, @@ -2617,6 +2724,8 @@ impl Session { msg, }; self.send_event_raw(event).await; + self.maybe_notify_parent_of_terminal_turn(turn_context, &legacy_source) + .await; self.maybe_mirror_event_text_to_realtime(&legacy_source) .await; self.maybe_clear_realtime_handoff_for_event(&legacy_source) @@ -2632,6 +2741,73 @@ impl Session { } } + /// Forwards terminal turn events from spawned MultiAgentV2 children to their direct parent. + async fn maybe_notify_parent_of_terminal_turn( + &self, + turn_context: &TurnContext, + msg: &EventMsg, + ) { + if !self.enabled(Feature::MultiAgentV2) { + return; + } + + if !matches!(msg, EventMsg::TurnComplete(_) | EventMsg::TurnAborted(_)) { + return; + } + + let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + agent_path: Some(child_agent_path), + .. + }) = &turn_context.session_source + else { + return; + }; + + let Some(status) = agent_status_from_event(msg) else { + return; + }; + if !is_final(&status) { + return; + } + + self.forward_child_completion_to_parent(*parent_thread_id, child_agent_path, status) + .await; + } + + /// Sends the standard completion envelope from a spawned MultiAgentV2 child to its parent. + async fn forward_child_completion_to_parent( + &self, + parent_thread_id: ThreadId, + child_agent_path: &codex_protocol::AgentPath, + status: AgentStatus, + ) { + let Some(parent_agent_path) = child_agent_path + .as_str() + .rsplit_once('/') + .and_then(|(parent, _)| codex_protocol::AgentPath::try_from(parent).ok()) + else { + return; + }; + + let message = format_subagent_notification_message(child_agent_path.as_str(), &status); + let communication = InterAgentCommunication::new( + child_agent_path.clone(), + parent_agent_path, + Vec::new(), + message, + /*trigger_turn*/ false, + ); + if let Err(err) = self + .services + .agent_control + .send_inter_agent_communication(parent_thread_id, communication) + .await + { + debug!("failed to notify parent thread {parent_thread_id}: {err}"); + } + } + async fn maybe_mirror_event_text_to_realtime(&self, msg: &EventMsg) { let Some(text) = realtime_text_for_event(msg) else { return; @@ -2892,7 +3068,6 @@ impl Session { network_approval_context: Option, proposed_execpolicy_amendment: Option, additional_permissions: Option, - skill_metadata: Option, available_decisions: Option>, ) -> ReviewDecision { // command-level approvals use `call_id`. @@ -2946,7 +3121,6 @@ impl Session { proposed_execpolicy_amendment, proposed_network_policy_amendments, additional_permissions, - skill_metadata, available_decisions: Some(available_decisions), parsed_cmd, }); @@ -3411,6 +3585,7 @@ impl Session { self.persist_rollout_items(&[RolloutItem::TurnContext(turn_context_item)]) .await; } + self.services.model_client.advance_window_generation(); } async fn persist_rollout_response_items(&self, items: &[ResponseItem]) { @@ -3464,7 +3639,11 @@ impl Session { state.reference_context_item(), state.previous_turn_settings(), state.session_configuration.collaboration_mode.clone(), - state.session_configuration.base_instructions.clone(), + state + .session_configuration + .base_instructions + .clone() + .unwrap_or_default(), state.session_configuration.session_source.clone(), ) }; @@ -3476,22 +3655,24 @@ impl Session { { developer_sections.push(model_switch_message.into_text()); } - developer_sections.push( - DeveloperInstructions::from_policy( - turn_context.sandbox_policy.get(), - turn_context.approval_policy.value(), - turn_context.config.approvals_reviewer, - self.services.exec_policy.current().as_ref(), - &turn_context.cwd, - turn_context - .features - .enabled(Feature::ExecPermissionApprovals), - turn_context - .features - .enabled(Feature::RequestPermissionsTool), - ) - .into_text(), - ); + if turn_context.config.include_permissions_instructions { + developer_sections.push( + DeveloperInstructions::from_policy( + turn_context.sandbox_policy.get(), + turn_context.approval_policy.value(), + turn_context.config.approvals_reviewer, + self.services.exec_policy.current().as_ref(), + &turn_context.cwd, + turn_context + .features + .enabled(Feature::ExecPermissionApprovals), + turn_context + .features + .enabled(Feature::RequestPermissionsTool), + ) + .into_text(), + ); + } let separate_guardian_developer_message = crate::guardian::is_guardian_reviewer_source(&session_source); // Keep the guardian policy prompt out of the aggregated developer bundle so it @@ -3541,7 +3722,7 @@ impl Session { ); } } - if turn_context.apps_enabled() { + if turn_context.config.include_apps_instructions && turn_context.apps_enabled() { let mcp_connection_manager = self.services.mcp_connection_manager.read().await; let accessible_and_enabled_connectors = connectors::list_accessible_and_enabled_connectors_from_manager( @@ -3584,16 +3765,18 @@ impl Session { .serialize_to_text(), ); } - let subagents = self - .services - .agent_control - .format_environment_context_subagents(self.conversation_id) - .await; - contextual_user_sections.push( - EnvironmentContext::from_turn_context(turn_context, shell.as_ref()) - .with_subagents(subagents) - .serialize_to_xml(), - ); + if turn_context.config.include_environment_context { + let subagents = self + .services + .agent_control + .format_environment_context_subagents(self.conversation_id) + .await; + contextual_user_sections.push( + EnvironmentContext::from_turn_context(turn_context, shell.as_ref()) + .with_subagents(subagents) + .serialize_to_xml(), + ); + } let mut items = Vec::with_capacity(3); if let Some(developer_message) = @@ -3701,7 +3884,13 @@ impl Session { pub(crate) async fn recompute_token_usage(&self, turn_context: &TurnContext) { let history = self.clone_history().await; - let base_instructions = self.get_base_instructions().await; + let empty_base_instructions = BaseInstructions { + text: String::new(), + }; + let base_instructions = self + .get_base_instructions() + .await + .unwrap_or(empty_base_instructions); let Some(estimated_total_tokens) = history.estimate_token_count_with_base_instructions(&base_instructions) else { @@ -3926,6 +4115,7 @@ impl Session { let mut turn_state = active_turn.turn_state.lock().await; turn_state.push_pending_input(input.into()); + turn_state.accept_mailbox_delivery_for_current_turn(); Ok(active_turn_id.clone()) } @@ -3947,6 +4137,54 @@ impl Session { } } + pub(crate) async fn defer_mailbox_delivery_to_next_turn(&self, sub_id: &str) { + let turn_state = self.turn_state_for_sub_id(sub_id).await; + let Some(turn_state) = turn_state else { + return; + }; + let mut turn_state = turn_state.lock().await; + if turn_state.has_pending_input() { + return; + } + turn_state.set_mailbox_delivery_phase(MailboxDeliveryPhase::NextTurn); + } + + pub(crate) async fn accept_mailbox_delivery_for_current_turn(&self, sub_id: &str) { + let turn_state = self.turn_state_for_sub_id(sub_id).await; + let Some(turn_state) = turn_state else { + return; + }; + turn_state + .lock() + .await + .set_mailbox_delivery_phase(MailboxDeliveryPhase::CurrentTurn); + } + + async fn turn_state_for_sub_id( + &self, + sub_id: &str, + ) -> Option>> { + let active = self.active_turn.lock().await; + active.as_ref().and_then(|active_turn| { + active_turn + .tasks + .contains_key(sub_id) + .then(|| Arc::clone(&active_turn.turn_state)) + }) + } + + pub(crate) fn subscribe_mailbox_seq(&self) -> watch::Receiver { + self.mailbox.subscribe() + } + + pub(crate) fn enqueue_mailbox_communication(&self, communication: InterAgentCommunication) { + self.mailbox.send(communication); + } + + pub(crate) async fn has_trigger_turn_mailbox_items(&self) -> bool { + self.mailbox_rx.lock().await.has_pending_trigger_turn() + } + pub async fn prepend_pending_input(&self, input: Vec) -> Result<(), ()> { let mut active = self.active_turn.lock().await; match active.as_mut() { @@ -3960,28 +4198,43 @@ impl Session { } pub async fn get_pending_input(&self) -> Vec { - let mut active = self.active_turn.lock().await; - match active.as_mut() { - Some(at) => { - let mut ts = at.turn_state.lock().await; - ts.take_pending_input() + let (pending_input, accepts_mailbox_delivery) = { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ( + ts.take_pending_input(), + ts.accepts_mailbox_delivery_for_current_turn(), + ) + } + None => (Vec::new(), true), } - None => Vec::with_capacity(0), + }; + if !accepts_mailbox_delivery { + return pending_input; } - } - - pub(crate) async fn pending_input_snapshot(&self) -> Vec { - let active = self.active_turn.lock().await; - match active.as_ref() { - Some(at) => { - let ts = at.turn_state.lock().await; - ts.pending_input_snapshot() - } - None => Vec::with_capacity(0), + let mailbox_items = { + let mut mailbox_rx = self.mailbox_rx.lock().await; + mailbox_rx + .drain() + .into_iter() + .map(|mail| mail.to_response_input_item()) + .collect::>() + }; + if pending_input.is_empty() { + mailbox_items + } else if mailbox_items.is_empty() { + pending_input + } else { + let mut pending_input = pending_input; + pending_input.extend(mailbox_items); + pending_input } } /// Queue response items to be injected into the next active turn created for this session. + #[cfg(test)] pub(crate) async fn queue_response_items_for_next_turn(&self, items: Vec) { if items.is_empty() { return; @@ -3995,25 +4248,31 @@ impl Session { std::mem::take(&mut *self.idle_pending_input.lock().await) } - pub(crate) async fn queued_response_items_for_next_turn_snapshot( - &self, - ) -> Vec { - self.idle_pending_input.lock().await.clone() - } - pub(crate) async fn has_queued_response_items_for_next_turn(&self) -> bool { !self.idle_pending_input.lock().await.is_empty() } pub async fn has_pending_input(&self) -> bool { - let active = self.active_turn.lock().await; - match active.as_ref() { - Some(at) => { - let ts = at.turn_state.lock().await; - ts.has_pending_input() + let (has_turn_pending_input, accepts_mailbox_delivery) = { + let active = self.active_turn.lock().await; + match active.as_ref() { + Some(at) => { + let ts = at.turn_state.lock().await; + ( + ts.has_pending_input(), + ts.accepts_mailbox_delivery_for_current_turn(), + ) + } + None => (false, true), } - None => false, + }; + if has_turn_pending_input { + return true; } + if !accepts_mailbox_delivery { + return false; + } + self.mailbox_rx.lock().await.has_pending() } pub async fn list_resources( @@ -4138,16 +4397,12 @@ impl Session { ) { let auth = self.services.auth_manager.auth().await; let config = self.get_config().await; + let mcp_config = config.to_mcp_config(self.services.plugins_manager.as_ref()); let tool_plugin_provenance = self .services .mcp_manager .tool_plugin_provenance(config.as_ref()); - let mcp_servers = with_codex_apps_mcp( - mcp_servers, - self.features.apps_enabled_for_auth(auth.as_ref()), - auth.as_ref(), - config.as_ref(), - ); + let mcp_servers = with_codex_apps_mcp(mcp_servers, auth.as_ref(), &mcp_config); let auth_statuses = compute_auth_statuses(mcp_servers.iter(), store_mode).await; let sandbox_state = SandboxState { sandbox_policy: turn_context.sandbox_policy.get().clone(), @@ -4165,6 +4420,7 @@ impl Session { store_mode, auth_statuses, &turn_context.config.permissions.approval_policy, + turn_context.sub_id.clone(), self.get_tx_event(), sandbox_state, config.codex_home.clone(), @@ -4249,6 +4505,37 @@ impl Session { } } +pub(crate) fn emit_subagent_session_started( + analytics_events_client: &AnalyticsEventsClient, + client_metadata: AppServerClientMetadata, + thread_id: ThreadId, + thread_config: ThreadConfigSnapshot, + subagent_source: SubAgentSource, +) { + let AppServerClientMetadata { + client_name, + client_version, + } = client_metadata; + let (Some(client_name), Some(client_version)) = (client_name, client_version) else { + tracing::warn!("skipping subagent thread analytics: missing inherited client metadata"); + return; + }; + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + analytics_events_client.track_subagent_thread_started(SubAgentThreadStartedInput { + thread_id: thread_id.to_string(), + product_client_id: client_name.clone(), + client_name, + client_version, + model: thread_config.model, + ephemeral: thread_config.ephemeral, + subagent_source, + created_at, + }); +} + async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiver) { // To break out of this loop, send Op::Shutdown. while let Ok(sub) = rx_sub.recv().await { @@ -4392,10 +4679,6 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv handlers::reload_user_config(&sess).await; false } - Op::ListCustomPrompts => { - handlers::list_custom_prompts(&sess, sub.id.clone()).await; - false - } Op::ListSkills { cwds, force_reload } => { handlers::list_skills(&sess, sub.id.clone(), cwds, force_reload).await; false @@ -4511,8 +4794,6 @@ mod handlers { use codex_features::Feature; use codex_utils_absolute_path::AbsolutePathBuf; - use crate::mcp::auth::compute_auth_statuses; - use crate::mcp::collect_mcp_snapshot_from_manager; use crate::review_prompts::resolve_review_request; use crate::rollout::RolloutRecorder; use crate::rollout::session_index; @@ -4521,13 +4802,13 @@ mod handlers { use crate::tasks::UserShellCommandMode; use crate::tasks::UserShellCommandTask; use crate::tasks::execute_user_shell_command; - use codex_protocol::custom_prompts::CustomPrompt; + use codex_mcp::collect_mcp_snapshot_from_manager; + use codex_mcp::compute_auth_statuses; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::InterAgentCommunication; - use codex_protocol::protocol::ListCustomPromptsResponseEvent; use codex_protocol::protocol::ListSkillsResponseEvent; use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; @@ -4622,6 +4903,7 @@ mod handlers { final_output_json_schema: Some(final_output_json_schema), personality, app_server_client_name: None, + app_server_client_version: None, }, ) } @@ -4670,25 +4952,17 @@ mod handlers { } } - /// Records an inter-agent assistant envelope and, when requested, wakes the recipient by - /// starting a regular turn if the session is currently idle. + /// Records an inter-agent assistant envelope, then lets the shared pending-work scheduler + /// decide whether an idle session should start a regular turn. pub async fn inter_agent_communication( sess: &Arc, sub_id: String, communication: InterAgentCommunication, ) { - let pending_item = communication.to_response_input_item(); - if let Ok(()) = sess.inject_response_items(vec![pending_item.clone()]).await { - return; - } - - sess.queue_response_items_for_next_turn(vec![pending_item]) - .await; - if communication.trigger_turn { - let turn_context = sess.new_default_turn_with_sub_id(sub_id).await; - sess.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) - .await; - sess.spawn_task(turn_context, Vec::new(), crate::tasks::RegularTask::new()) + let trigger_turn = communication.trigger_turn; + sess.enqueue_mailbox_communication(communication); + if trigger_turn { + sess.maybe_start_turn_for_pending_work_with_sub_id(sub_id) .await; } } @@ -4869,7 +5143,7 @@ mod handlers { let event = Event { id: sub_id, msg: EventMsg::GetHistoryEntryResponse( - crate::protocol::GetHistoryEntryResponseEvent { + codex_protocol::protocol::GetHistoryEntryResponseEvent { offset, log_id, entry: entry_opt.map(|e| codex_protocol::message_history::HistoryEntry { @@ -4914,23 +5188,6 @@ mod handlers { sess.send_event_raw(event).await; } - pub async fn list_custom_prompts(sess: &Session, sub_id: String) { - let custom_prompts: Vec = - if let Some(dir) = crate::custom_prompts::default_prompts_dir() { - crate::custom_prompts::discover_prompts_in(&dir).await - } else { - Vec::new() - }; - - let event = Event { - id: sub_id, - msg: EventMsg::ListCustomPromptsResponse(ListCustomPromptsResponseEvent { - custom_prompts, - }), - }; - sess.send_event_raw(event).await; - } - pub async fn list_skills( sess: &Session, sub_id: String, @@ -5370,7 +5627,7 @@ async fn spawn_review_thread( let review_model_info = sess .services .models_manager - .get_model_info(&model, &config) + .get_model_info(&model, &config.to_models_manager_config()) .await; // For reviews, disable web_search and view_image regardless of global settings. let mut review_features = sess.features.clone(); @@ -5391,13 +5648,16 @@ async fn spawn_review_thread( windows_sandbox_level: parent_turn_context.windows_sandbox_level, }) .with_unified_exec_shell_mode_for_session( - sess.services.user_shell.as_ref(), + crate::tools::spec::tool_user_shell_type(sess.services.user_shell.as_ref()), sess.services.shell_zsh_path.as_ref(), sess.services.main_execve_wrapper_exe.as_ref(), ) .with_web_search_config(/*web_search_config*/ None) .with_allow_login_shell(config.permissions.allow_login_shell) - .with_agent_roles(config.agent_roles.clone()); + .with_has_environment(parent_turn_context.environment.is_some()) + .with_agent_type_description(crate::agent::role::spawn_tool_spec::build( + &config.agent_roles, + )); let review_prompt = resolved.prompt.clone(); let provider = parent_turn_context.provider.clone(); @@ -5453,7 +5713,7 @@ async fn spawn_review_thread( reasoning_effort, reasoning_summary, session_source, - environment: Arc::clone(&parent_turn_context.environment), + environment: parent_turn_context.environment.clone(), tools_config, features: parent_turn_context.features.clone(), ghost_snapshot: parent_turn_context.ghost_snapshot.clone(), @@ -5474,6 +5734,7 @@ async fn spawn_review_thread( shell_environment_policy: parent_turn_context.shell_environment_policy.clone(), cwd: parent_turn_context.cwd.clone(), final_output_json_schema: None, + codex_self_exe: parent_turn_context.codex_self_exe.clone(), codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(), tool_call_gate: Arc::new(ReadinessFlag::new()), js_repl: Arc::clone(&sess.js_repl), @@ -5587,16 +5848,20 @@ pub(crate) async fn run_turn( let model_info = turn_context.model_info.clone(); let auto_compact_limit = model_info.auto_compact_token_limit().unwrap_or(i64::MAX); + let mut prewarmed_client_session = prewarmed_client_session; // TODO(ccunningham): Pre-turn compaction runs before context updates and the // new user message are recorded. Estimate pending incoming items (context // diffs/full reinjection + user input) and trigger compaction preemptively // when they would push the thread over the compaction threshold. - if run_pre_sampling_compact(&sess, &turn_context) - .await - .is_err() - { - error!("Failed to run pre-sampling compact"); - return None; + let pre_sampling_compacted = match run_pre_sampling_compact(&sess, &turn_context).await { + Ok(pre_sampling_compacted) => pre_sampling_compacted, + Err(_) => { + error!("Failed to run pre-sampling compact"); + return None; + } + }; + if pre_sampling_compacted && let Some(mut client_session) = prewarmed_client_session.take() { + client_session.reset_websocket_session(); } let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); @@ -5909,6 +6174,7 @@ pub(crate) async fn run_turn( { return None; } + client_session.reset_websocket_session(); continue; } @@ -5935,7 +6201,7 @@ pub(crate) async fn run_turn( for run in sess.hooks().preview_stop(&stop_request) { sess.send_event( &turn_context, - EventMsg::HookStarted(crate::protocol::HookStartedEvent { + EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent { turn_id: Some(turn_context.sub_id.clone()), run, }), @@ -6069,9 +6335,9 @@ pub(crate) async fn run_turn( async fn run_pre_sampling_compact( sess: &Arc, turn_context: &Arc, -) -> CodexResult<()> { +) -> CodexResult { let total_usage_tokens_before_compaction = sess.get_total_token_usage().await; - maybe_run_previous_model_inline_compact( + let mut pre_sampling_compacted = maybe_run_previous_model_inline_compact( sess, turn_context, total_usage_tokens_before_compaction, @@ -6085,8 +6351,9 @@ async fn run_pre_sampling_compact( // Compact if the total usage tokens are greater than the auto compact limit if total_usage_tokens >= auto_compact_limit { run_auto_compact(sess, turn_context, InitialContextInjection::DoNotInject).await?; + pre_sampling_compacted = true; } - Ok(()) + Ok(pre_sampling_compacted) } /// Runs pre-sampling compaction against the previous model when switching to a smaller @@ -6285,10 +6552,10 @@ fn connector_inserted_in_messages( } fn filter_codex_apps_mcp_tools( - mcp_tools: &HashMap, + mcp_tools: &HashMap, connectors: &[connectors::AppInfo], config: &Config, -) -> HashMap { +) -> HashMap { let allowed: HashSet<&str> = connectors .iter() .map(|connector| connector.id.as_str()) @@ -6309,7 +6576,7 @@ fn filter_codex_apps_mcp_tools( .collect() } -fn codex_apps_connector_id(tool: &crate::mcp_connection_manager::ToolInfo) -> Option<&str> { +fn codex_apps_connector_id(tool: &McpToolInfo) -> Option<&str> { tool.connector_id.as_deref() } @@ -6317,7 +6584,7 @@ pub(crate) fn build_prompt( input: Vec, router: &ToolRouter, turn_context: &TurnContext, - base_instructions: BaseInstructions, + base_instructions: Option, ) -> Prompt { let deferred_dynamic_tools = turn_context .dynamic_tools @@ -6344,6 +6611,7 @@ pub(crate) fn build_prompt( output_schema: turn_context.final_output_json_schema.clone(), } } + #[allow(clippy::too_many_arguments)] #[instrument(level = "trace", skip_all, @@ -6540,7 +6808,7 @@ pub(crate) async fn built_tools( ) .await .map(|discoverable_tools| { - crate::tools::discoverable::filter_tool_suggest_discoverable_tools_for_client( + filter_tool_suggest_discoverable_tools_for_client( discoverable_tools, turn_context.app_server_client_name.as_deref(), ) @@ -6851,7 +7119,6 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::TurnDiff(_) | EventMsg::GetHistoryEntryResponse(_) | EventMsg::McpListToolsResponse(_) - | EventMsg::ListCustomPromptsResponse(_) | EventMsg::ListSkillsResponse(_) | EventMsg::SkillsUpdateAvailable | EventMsg::PlanUpdate(_) @@ -7281,6 +7548,25 @@ async fn try_run_sampling_request( cancellation_token: cancellation_token.child_token(), }; + let preempt_for_mailbox_mail = match &item { + ResponseItem::Message { role, phase, .. } => { + role == "assistant" && matches!(phase, Some(MessagePhase::Commentary)) + } + ResponseItem::Reasoning { .. } => true, + ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } + | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } + | ResponseItem::WebSearchCall { .. } + | ResponseItem::ImageGenerationCall { .. } + | ResponseItem::GhostSnapshot { .. } + | ResponseItem::Compaction { .. } + | ResponseItem::Other => false, + }; + let output_result = handle_output_item_done(&mut ctx, item, previously_active_item) .instrument(handle_responses) .await?; @@ -7291,6 +7577,13 @@ async fn try_run_sampling_request( last_agent_message = Some(agent_message); } needs_follow_up |= output_result.needs_follow_up; + // todo: remove before stabilizing multi-agent v2 + if preempt_for_mailbox_mail && sess.mailbox_rx.lock().await.has_pending() { + break Ok(SamplingRequestResult { + needs_follow_up: true, + last_agent_message, + }); + } } ResponseEvent::OutputItemAdded(item) => { if let Some(turn_item) = handle_non_tool_response_item( diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index e468bfceb5..753244ac2b 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -1,13 +1,13 @@ use super::*; -use crate::protocol::CompactedItem; -use crate::protocol::InitialHistory; -use crate::protocol::ResumedHistory; use codex_protocol::AgentPath; use codex_protocol::ThreadId; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::CompactedItem; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; +use codex_protocol::protocol::ResumedHistory; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -41,7 +41,7 @@ fn inter_agent_assistant_message(text: &str) -> ResponseItem { AgentPath::root().join("worker").unwrap(), Vec::new(), text.to_string(), - true, + /*trigger_turn*/ true, ); ResponseItem::Message { id: None, @@ -128,6 +128,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -145,6 +146,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -190,6 +193,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -209,11 +213,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -233,6 +240,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -280,6 +289,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -299,11 +309,14 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -365,6 +378,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -384,11 +398,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: second_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -407,11 +424,14 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: second_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -421,6 +441,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -471,6 +493,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -490,11 +513,14 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: assistant_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -506,6 +532,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: assistant_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -551,6 +579,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: only_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -570,6 +599,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding codex_protocol::protocol::TurnCompleteEvent { turn_id: only_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -599,6 +630,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: user_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -616,12 +648,15 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: user_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: standalone_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -630,6 +665,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -663,6 +700,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -680,11 +718,14 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -815,6 +856,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -832,6 +874,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -876,6 +920,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -898,6 +943,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -979,6 +1026,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -996,11 +1044,14 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: aborted_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1017,6 +1068,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnAbortedEvent { turn_id: None, reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::Compacted(CompactedItem { @@ -1080,6 +1133,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1097,11 +1151,14 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1118,6 +1175,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnAbortedEvent { turn_id: Some(unmatched_abort_turn_id), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1125,6 +1184,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1187,6 +1248,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1204,11 +1266,14 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1258,6 +1323,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: current_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1332,6 +1398,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: previous_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1349,11 +1416,14 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compacted_incomplete_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1375,6 +1445,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: replacing_turn_id, + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index d87dd070d5..aef0a92d76 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use async_channel::Receiver; use async_channel::Sender; use codex_async_utils::OrCancelExt; +use codex_exec_server::EnvironmentManager; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; @@ -29,15 +30,14 @@ use tokio::sync::oneshot; use tokio::time::timeout; use tokio_util::sync::CancellationToken; -use crate::AuthManager; use crate::codex::Codex; use crate::codex::CodexSpawnArgs; use crate::codex::CodexSpawnOk; use crate::codex::SUBMISSION_CHANNEL_CAPACITY; use crate::codex::Session; use crate::codex::TurnContext; +use crate::codex::emit_subagent_session_started; use crate::config::Config; -use crate::error::CodexErr; use crate::guardian::GuardianApprovalRequest; use crate::guardian::review_approval_request_with_cancel; use crate::guardian::routes_approval_to_guardian; @@ -47,7 +47,9 @@ use crate::mcp_tool_call::MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC; use crate::mcp_tool_call::build_guardian_mcp_tool_review_request; use crate::mcp_tool_call::is_mcp_tool_approval_question_id; use crate::mcp_tool_call::lookup_mcp_tool_metadata; -use crate::models_manager::manager::ModelsManager; +use codex_login::AuthManager; +use codex_models_manager::manager::ModelsManager; +use codex_protocol::error::CodexErr; use codex_protocol::protocol::InitialHistory; #[cfg(test)] @@ -76,12 +78,15 @@ pub(crate) async fn run_codex_thread_interactive( config, auth_manager, models_manager, + environment_manager: Arc::new(EnvironmentManager::from_environment( + parent_ctx.environment.as_deref(), + )), skills_manager: Arc::clone(&parent_session.services.skills_manager), plugins_manager: Arc::clone(&parent_session.services.plugins_manager), mcp_manager: Arc::clone(&parent_session.services.mcp_manager), skills_watcher: Arc::clone(&parent_session.services.skills_watcher), conversation_history: initial_history.unwrap_or(InitialHistory::New), - session_source: SessionSource::SubAgent(subagent_source), + session_source: SessionSource::SubAgent(subagent_source.clone()), agent_control: parent_session.services.agent_control.clone(), dynamic_tools: Vec::new(), persist_extended_history: false, @@ -92,6 +97,17 @@ pub(crate) async fn run_codex_thread_interactive( parent_trace: None, }) .await?; + if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { + let thread_config = codex.thread_config_snapshot().await; + let client_metadata = parent_session.app_server_client_metadata().await; + emit_subagent_session_started( + &parent_session.services.analytics_events_client, + client_metadata, + codex.session.conversation_id, + thread_config, + subagent_source, + ); + } let codex = Arc::new(codex); // Use a child token so parent cancel cascades but we can scope it to this task @@ -432,7 +448,6 @@ async fn handle_exec_approval( network_approval_context, proposed_execpolicy_amendment, additional_permissions, - skill_metadata, available_decisions, .. } = event; @@ -476,7 +491,6 @@ async fn handle_exec_approval( network_approval_context, proposed_execpolicy_amendment, additional_permissions, - skill_metadata, available_decisions, ), parent_session, @@ -514,66 +528,60 @@ async fn handle_patch_approval( } = event; let approval_id = call_id.clone(); let guardian_decision = if routes_approval_to_guardian(parent_ctx) { - let change_count = changes.len(); - let maybe_files = changes + let files = changes .keys() - .map(|path| parent_ctx.cwd.join(path).ok()) - .collect::>>(); - if let Some(files) = maybe_files { - let review_cancel = cancel_token.child_token(); - let patch = changes - .iter() - .map(|(path, change)| match change { - codex_protocol::protocol::FileChange::Add { content } => { - format!("*** Add File: {}\n{}", path.display(), content) + .map(|path| parent_ctx.cwd.join(path)) + .collect::>(); + let review_cancel = cancel_token.child_token(); + let patch = changes + .iter() + .map(|(path, change)| match change { + codex_protocol::protocol::FileChange::Add { content } => { + format!("*** Add File: {}\n{}", path.display(), content) + } + codex_protocol::protocol::FileChange::Delete { content } => { + format!("*** Delete File: {}\n{}", path.display(), content) + } + codex_protocol::protocol::FileChange::Update { + unified_diff, + move_path, + } => { + if let Some(move_path) = move_path { + format!( + "*** Update File: {}\n*** Move to: {}\n{}", + path.display(), + move_path.display(), + unified_diff + ) + } else { + format!("*** Update File: {}\n{}", path.display(), unified_diff) } - codex_protocol::protocol::FileChange::Delete { content } => { - format!("*** Delete File: {}\n{}", path.display(), content) - } - codex_protocol::protocol::FileChange::Update { - unified_diff, - move_path, - } => { - if let Some(move_path) = move_path { - format!( - "*** Update File: {}\n*** Move to: {}\n{}", - path.display(), - move_path.display(), - unified_diff - ) - } else { - format!("*** Update File: {}\n{}", path.display(), unified_diff) - } - } - }) - .collect::>() - .join("\n"); - let review_rx = spawn_guardian_review( - Arc::clone(parent_session), - Arc::clone(parent_ctx), - GuardianApprovalRequest::ApplyPatch { - id: approval_id.clone(), - cwd: parent_ctx.cwd.to_path_buf(), - files, - change_count, - patch, - }, - reason.clone(), - review_cancel.clone(), - ); - Some( - await_approval_with_cancel( - async move { review_rx.await.unwrap_or_default() }, - parent_session, - &approval_id, - cancel_token, - Some(&review_cancel), - ) - .await, + } + }) + .collect::>() + .join("\n"); + let review_rx = spawn_guardian_review( + Arc::clone(parent_session), + Arc::clone(parent_ctx), + GuardianApprovalRequest::ApplyPatch { + id: approval_id.clone(), + cwd: parent_ctx.cwd.to_path_buf(), + files, + patch, + }, + reason.clone(), + review_cancel.clone(), + ); + Some( + await_approval_with_cancel( + async move { review_rx.await.unwrap_or_default() }, + parent_session, + &approval_id, + cancel_token, + Some(&review_cancel), ) - } else { - None - } + .await, + ) } else { None }; diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 8201424d8e..fbdeb765a8 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -9,8 +9,10 @@ use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecApprovalRequestEvent; +use codex_protocol::protocol::GuardianAssessmentAction; use codex_protocol::protocol::GuardianAssessmentEvent; use codex_protocol::protocol::GuardianAssessmentStatus; +use codex_protocol::protocol::GuardianCommandSource; use codex_protocol::protocol::McpInvocation; use codex_protocol::protocol::RawResponseItemEvent; use codex_protocol::protocol::ReviewDecision; @@ -23,7 +25,6 @@ use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::request_user_input::RequestUserInputQuestion; use pretty_assertions::assert_eq; -use serde_json::json; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -52,6 +53,8 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { msg: EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, + completed_at: None, + duration_ms: None, }), }) .await @@ -286,7 +289,6 @@ async fn handle_exec_approval_uses_call_id_for_guardian_review_and_approval_id_f proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, additional_permissions: None, - skill_metadata: None, available_decisions: Some(vec![ ReviewDecision::Approved, ReviewDecision::Abort, @@ -318,11 +320,11 @@ async fn handle_exec_approval_uses_call_id_for_guardian_review_and_approval_id_f risk_score: None, risk_level: None, rationale: None, - action: Some(json!({ - "tool": "shell", - "command": "rm -rf tmp", - "cwd": "/tmp", - })), + action: GuardianAssessmentAction::Command { + source: GuardianCommandSource::Shell, + command: "rm -rf tmp".to_string(), + cwd: "/tmp".into(), + }, } ); diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index ca749cba74..a1c05e5bb0 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1,22 +1,27 @@ use super::*; -use crate::CodexAuth; use crate::config::ConfigBuilder; use crate::config::test_config; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigLayerStackOrdering; use crate::config_loader::NetworkConstraints; +use crate::config_loader::NetworkDomainPermissionToml; +use crate::config_loader::NetworkDomainPermissionsToml; use crate::config_loader::RequirementSource; use crate::config_loader::Sourced; use crate::exec::ExecCapturePolicy; -use crate::exec::ExecToolCallOutput; use crate::function_tool::FunctionCallError; -use crate::mcp_connection_manager::ToolInfo; -use crate::models_manager::model_info; use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; use codex_features::Features; +use codex_login::CodexAuth; +use codex_mcp::ToolInfo; +use codex_model_provider_info::ModelProviderInfo; +use codex_models_manager::bundled_models_response; +use codex_models_manager::model_info; +use codex_protocol::AgentPath; use codex_protocol::ThreadId; +use codex_protocol::exec_output::ExecToolCallOutput; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::permissions::FileSystemAccessMode; @@ -31,28 +36,13 @@ use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use tracing::Span; -use crate::protocol::CompactedItem; -use crate::protocol::CreditsSnapshot; -use crate::protocol::InitialHistory; -use crate::protocol::NetworkApprovalProtocol; -use crate::protocol::RateLimitSnapshot; -use crate::protocol::RateLimitWindow; -use crate::protocol::ResumedHistory; -use crate::protocol::RolloutItem; -use crate::protocol::TokenCountEvent; -use crate::protocol::TokenUsage; -use crate::protocol::TokenUsageInfo; -use crate::protocol::TurnCompleteEvent; -use crate::protocol::TurnStartedEvent; -use crate::protocol::UserMessageEvent; +use crate::RolloutRecorderParams; use crate::rollout::policy::EventPersistenceMode; use crate::rollout::recorder::RolloutRecorder; -use crate::rollout::recorder::RolloutRecorderParams; use crate::state::TaskKind; use crate::tasks::SessionTask; use crate::tasks::SessionTaskContext; use crate::tools::ToolRouter; -use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::handlers::ShellHandler; @@ -74,11 +64,28 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::DeveloperInstructions; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; -use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::ConversationAudioParams; +use codex_protocol::protocol::CreditsSnapshot; +use codex_protocol::protocol::GranularApprovalConfig; +use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::InterAgentCommunication; +use codex_protocol::protocol::NetworkApprovalProtocol; +use codex_protocol::protocol::RateLimitSnapshot; +use codex_protocol::protocol::RateLimitWindow; use codex_protocol::protocol::RealtimeAudioFrame; +use codex_protocol::protocol::ResumedHistory; +use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::Submission; +use codex_protocol::protocol::ThreadRolledBackEvent; +use codex_protocol::protocol::TokenCountEvent; +use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TokenUsageInfo; +use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::protocol::TurnCompleteEvent; +use codex_protocol::protocol::TurnStartedEvent; +use codex_protocol::protocol::UserMessageEvent; use codex_protocol::protocol::W3cTraceContext; use core_test_support::PathBufExt; use core_test_support::context_snapshot; @@ -112,12 +119,6 @@ use std::time::Duration as StdDuration; #[path = "codex_tests_guardian.rs"] mod guardian_tests; -use codex_protocol::models::function_call_output_content_items_to_text; - -fn expect_text_tool_output(output: &FunctionToolOutput) -> String { - function_call_output_content_items_to_text(&output.body).unwrap_or_default() -} - struct InstructionsTestCase { slug: &'static str, expects_apply_patch_instructions: bool, @@ -234,28 +235,33 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted .await .expect("expected turn aborted event") .expect("channel open"); - assert!(matches!( - second.msg, - EventMsg::TurnAborted(crate::protocol::TurnAbortedEvent { - turn_id: Some(turn_id), - reason: TurnAbortReason::Interrupted, - }) if turn_id == tc.sub_id - )); + let EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, + reason, + completed_at, + duration_ms, + }) = second.msg + else { + panic!("expected turn aborted event"); + }; + assert_eq!(turn_id, Some(tc.sub_id.clone())); + assert_eq!(reason, TurnAbortReason::Interrupted); + assert!(completed_at.is_some()); + assert!(duration_ms.is_some()); } fn test_model_client_session() -> crate::client::ModelClientSession { crate::client::ModelClient::new( - None, + /*auth_manager*/ None, ThreadId::try_from("00000000-0000-4000-8000-000000000001") .expect("test thread id should be valid"), - crate::model_provider_info::ModelProviderInfo::create_openai_provider( - /* base_url */ None, - ), + /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), + ModelProviderInfo::create_openai_provider(/* base_url */ /*base_url*/ None), codex_protocol::protocol::SessionSource::Exec, - None, - false, - false, - None, + /*model_verbosity*/ None, + /*enable_request_compression*/ false, + /*include_timing_metrics*/ false, + /*beta_features_header*/ None, ) .new_session() } @@ -277,6 +283,23 @@ fn developer_input_texts(items: &[ResponseItem]) -> Vec<&str> { .collect() } +fn user_input_texts(items: &[ResponseItem]) -> Vec<&str> { + items + .iter() + .filter_map(|item| match item { + ResponseItem::Message { role, content, .. } if role == "user" => { + Some(content.as_slice()) + } + _ => None, + }) + .flat_map(|content| content.iter()) + .filter_map(|item| match item { + ContentItem::InputText { text } => Some(text.as_str()), + _ => None, + }) + .collect() +} + fn test_tool_runtime(session: Arc, turn_context: Arc) -> ToolCallRuntime { let router = Arc::new(ToolRouter::from_config( &turn_context.tools_config, @@ -311,7 +334,7 @@ fn make_connector(id: &str, name: &str) -> AppInfo { #[test] fn assistant_message_stream_parsers_can_be_seeded_from_output_item_added_text() { - let mut parsers = AssistantMessageStreamParsers::new(false); + let mut parsers = AssistantMessageStreamParsers::new(/*plan_mode*/ false); let item_id = "msg-1"; let seeded = parsers.seed_item_text(item_id, "hello doc"); @@ -328,7 +351,7 @@ fn assistant_message_stream_parsers_can_be_seeded_from_output_item_added_text() #[test] fn assistant_message_stream_parsers_seed_buffered_prefix_stays_out_of_finish_tail() { - let mut parsers = AssistantMessageStreamParsers::new(false); + let mut parsers = AssistantMessageStreamParsers::new(/*plan_mode*/ false); let item_id = "msg-1"; let seeded = parsers.seed_item_text(item_id, "hello anyhow::Result<()> { let spec = crate::config::NetworkProxySpec::from_config_and_constraints( NetworkProxyConfig::default(), - None, + /*requirements*/ None, &SandboxPolicy::new_workspace_write_policy(), )?; let mut exec_policy = Policy::empty(); @@ -455,24 +478,24 @@ async fn start_managed_network_proxy_applies_execpolicy_network_rules() -> anyho "example.com", NetworkRuleProtocol::Https, Decision::Allow, - None, + /*justification*/ None, )?; let (started_proxy, _) = Session::start_managed_network_proxy( &spec, &exec_policy, &SandboxPolicy::new_workspace_write_policy(), - None, - None, - false, + /*network_policy_decider*/ None, + /*blocked_request_observer*/ None, + /*managed_network_requirements_enabled*/ false, crate::config::NetworkProxyAuditMetadata::default(), ) .await?; let current_cfg = started_proxy.proxy().current_cfg().await?; assert_eq!( - current_cfg.network.allowed_domains, - vec!["example.com".to_string()] + current_cfg.network.allowed_domains(), + Some(vec!["example.com".to_string()]) ); Ok(()) } @@ -483,7 +506,12 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules() let spec = crate::config::NetworkProxySpec::from_config_and_constraints( NetworkProxyConfig::default(), Some(NetworkConstraints { - allowed_domains: Some(vec!["managed.example.com".to_string()]), + domains: Some(NetworkDomainPermissionsToml { + entries: std::collections::BTreeMap::from([( + "managed.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + }), managed_allowed_domains_only: Some(true), ..Default::default() }), @@ -494,24 +522,24 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules() "example.com", NetworkRuleProtocol::Https, Decision::Allow, - None, + /*justification*/ None, )?; let (started_proxy, _) = Session::start_managed_network_proxy( &spec, &exec_policy, &SandboxPolicy::new_workspace_write_policy(), - None, - None, - false, + /*network_policy_decider*/ None, + /*blocked_request_observer*/ None, + /*managed_network_requirements_enabled*/ false, crate::config::NetworkProxyAuditMetadata::default(), ) .await?; let current_cfg = started_proxy.proxy().current_cfg().await?; assert_eq!( - current_cfg.network.allowed_domains, - vec!["managed.example.com".to_string()] + current_cfg.network.allowed_domains(), + Some(vec!["managed.example.com".to_string()]) ); Ok(()) } @@ -520,8 +548,8 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules() async fn get_base_instructions_no_user_content() { let prompt_with_apply_patch_instructions = include_str!("../prompt_with_apply_patch_instructions.md"); - let models_response: ModelsResponse = - serde_json::from_str(include_str!("../models.json")).expect("valid models.json"); + let models_response = bundled_models_response() + .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); let model_info_for_slug = |slug: &str, config: &Config| { let model = models_response .models @@ -529,7 +557,7 @@ async fn get_base_instructions_no_user_content() { .find(|candidate| candidate.slug == slug) .cloned() .unwrap_or_else(|| panic!("model slug {slug} is missing from models.json")); - model_info::with_config_overrides(model, config) + model_info::with_config_overrides(model, &config.to_models_manager_config()) }; let test_cases = vec![ InstructionsTestCase { @@ -564,11 +592,15 @@ async fn get_base_instructions_no_user_content() { { let mut state = session.state.lock().await; - state.session_configuration.base_instructions = model_info.base_instructions.clone(); + state.session_configuration.base_instructions = + Some(model_info.base_instructions.clone()); } let base_instructions = session.get_base_instructions().await; - assert_eq!(base_instructions.text, model_info.base_instructions); + assert_eq!( + base_instructions.expect("base instructions").text, + model_info.base_instructions + ); } } @@ -594,7 +626,7 @@ async fn reload_user_config_layer_updates_effective_apps_config() { .and_then(|table| table.get("apps")) .cloned() .expect("apps table"); - let apps = crate::config::types::AppsConfigToml::deserialize(apps_toml) + let apps = codex_config::types::AppsConfigToml::deserialize(apps_toml) .expect("deserialize apps config"); let app = apps .apps @@ -730,7 +762,9 @@ async fn non_app_mcp_tools_remain_visible_without_search_selection() { ), ( "mcp__rmcp__echo".to_string(), - make_mcp_tool("rmcp", "echo", None, None), + make_mcp_tool( + "rmcp", "echo", /*connector_id*/ None, /*connector_name*/ None, + ), ), ]); @@ -778,7 +812,9 @@ async fn search_tool_selection_keeps_codex_apps_tools_without_mentions() { ), ( "mcp__rmcp__echo".to_string(), - make_mcp_tool("rmcp", "echo", None, None), + make_mcp_tool( + "rmcp", "echo", /*connector_id*/ None, /*connector_name*/ None, + ), ), ]); @@ -828,7 +864,9 @@ async fn apps_mentions_add_codex_apps_tools_to_search_selected_set() { ), ( "mcp__rmcp__echo".to_string(), - make_mcp_tool("rmcp", "echo", None, None), + make_mcp_tool( + "rmcp", "echo", /*connector_id*/ None, /*connector_name*/ None, + ), ), ]); @@ -1058,7 +1096,7 @@ async fn recompute_token_usage_uses_session_base_instructions() { let override_instructions = "SESSION_OVERRIDE_INSTRUCTIONS_ONLY".repeat(120); { let mut state = session.state.lock().await; - state.session_configuration.base_instructions = override_instructions.clone(); + state.session_configuration.base_instructions = Some(override_instructions.clone()); } let item = user_message("hello"); @@ -1201,42 +1239,23 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - // The parent rollout writer drains asynchronously after turn completion. - // Wait until the persisted JSONL includes the source user turn before forking from it. - let mut source_history_persisted = false; - for _ in 0..100 { - let history = RolloutRecorder::get_rollout_history(&rollout_path).await; - source_history_persisted = history.ok().is_some_and(|history| { - history.get_rollout_items().into_iter().any(|item| { - matches!( - item, - RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) - if role == "user" - && content.iter().any(|content_item| { - matches!( - content_item, - ContentItem::InputText { text } if text == "fork seed" - ) - }) - ) - }) - }); - if source_history_persisted { - break; - } - sleep(StdDuration::from_millis(10)).await; - } - assert!( - source_history_persisted, - "source rollout should contain the completed pre-fork user turn before forking" - ); + // Forking reads the persisted rollout JSONL, so force the completed source turn to disk + // before snapshotting from it. + initial.codex.ensure_rollout_materialized().await; + initial.codex.flush_rollout().await; let mut fork_config = initial.config.clone(); fork_config.permissions.approval_policy = codex_config::Constrained::allow_any(AskForApproval::UnlessTrusted); let forked = initial .thread_manager - .fork_thread(usize::MAX, fork_config, rollout_path, false, None) + .fork_thread( + usize::MAX, + fork_config, + rollout_path, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) .await?; let collaboration_mode = CollaborationMode { @@ -1332,6 +1351,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1349,6 +1369,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, }, )), ]; @@ -1409,7 +1431,7 @@ async fn thread_rollback_drops_last_turn_from_history() { state.set_reference_context_item(Some(tc.to_turn_context_item())); } - handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; let rollback_event = wait_for_thread_rolled_back(&rx).await; assert_eq!(rollback_event.num_turns, 1); @@ -1456,7 +1478,7 @@ async fn thread_rollback_clears_history_when_num_turns_exceeds_existing_turns() .collect(); sess.persist_rollout_items(&rollout_items).await; - handlers::thread_rollback(&sess, "sub-1".to_string(), 99).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 99).await; let rollback_event = wait_for_thread_rolled_back(&rx).await; assert_eq!(rollback_event.num_turns, 99); @@ -1473,7 +1495,7 @@ async fn thread_rollback_fails_without_persisted_rollout_path() { sess.record_into_history(&initial_context, tc.as_ref()) .await; - handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; let error_event = wait_for_thread_rollback_failed(&rx).await; assert_eq!( @@ -1513,6 +1535,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1531,10 +1554,13 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1553,6 +1579,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1567,7 +1595,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context })) .await; - handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; let rollback_event = wait_for_thread_rolled_back(&rx).await; assert_eq!(rollback_event.num_turns, 1); @@ -1611,6 +1639,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: first_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1627,10 +1656,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: compact_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1642,10 +1674,13 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: rolled_back_turn_id.clone(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1666,6 +1701,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; @@ -1675,7 +1712,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio ) .await; - handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; let rollback_event = wait_for_thread_rolled_back(&rx).await; assert_eq!(rollback_event.num_turns, 1); @@ -1693,6 +1730,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1709,10 +1747,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-2".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1729,10 +1770,13 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-3".to_string(), + started_at: None, model_context_window: Some(128_000), collaboration_mode_kind: ModeKind::Default, }, @@ -1749,14 +1793,16 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-3".to_string(), last_agent_message: None, + completed_at: None, + duration_ms: None, })), ]) .await; - handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; let first_rollback = wait_for_thread_rolled_back(&rx).await; assert_eq!(first_rollback.num_turns, 1); - handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; let second_rollback = wait_for_thread_rolled_back(&rx).await; assert_eq!(second_rollback.num_turns, 1); @@ -1791,7 +1837,7 @@ async fn thread_rollback_fails_when_turn_in_progress() { .await; *sess.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; let error_event = wait_for_thread_rollback_failed(&rx).await; assert_eq!( @@ -1811,7 +1857,7 @@ async fn thread_rollback_fails_when_num_turns_is_zero() { sess.record_into_history(&initial_context, tc.as_ref()) .await; - handlers::thread_rollback(&sess, "sub-1".to_string(), 0).await; + handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 0).await; let error_event = wait_for_thread_rollback_failed(&rx).await; assert_eq!(error_event.message, "num_turns must be >= 1"); @@ -1830,7 +1876,10 @@ async fn set_rate_limits_retains_previous_credits() { let config = build_test_config(codex_home.path()).await; let config = Arc::new(config); let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref()); - let model_info = ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config); + let model_info = ModelsManager::construct_model_info_offline_for_tests( + model.as_str(), + &config.to_models_manager_config(), + ); let reasoning_effort = config.model_reasoning_effort; let collaboration_mode = CollaborationMode { mode: ModeKind::Default, @@ -1845,13 +1894,14 @@ async fn set_rate_limits_retains_previous_credits() { collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), + developer_instructions_override: config.developer_instructions_override.clone(), user_instructions: config.user_instructions.clone(), service_tier: None, personality: config.personality, base_instructions: config .base_instructions .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + .unwrap_or_else(|| Some(model_info.get_model_instructions(config.personality))), compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, @@ -1865,6 +1915,7 @@ async fn set_rate_limits_retains_previous_credits() { original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, + app_server_client_version: None, session_source: SessionSource::Exec, dynamic_tools: Vec::new(), persist_extended_history: false, @@ -1928,7 +1979,10 @@ async fn set_rate_limits_updates_plan_type_when_present() { let config = build_test_config(codex_home.path()).await; let config = Arc::new(config); let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref()); - let model_info = ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config); + let model_info = ModelsManager::construct_model_info_offline_for_tests( + model.as_str(), + &config.to_models_manager_config(), + ); let reasoning_effort = config.model_reasoning_effort; let collaboration_mode = CollaborationMode { mode: ModeKind::Default, @@ -1943,13 +1997,14 @@ async fn set_rate_limits_updates_plan_type_when_present() { collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), + developer_instructions_override: config.developer_instructions_override.clone(), user_instructions: config.user_instructions.clone(), service_tier: None, personality: config.personality, base_instructions: config .base_instructions .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + .unwrap_or_else(|| Some(model_info.get_model_instructions(config.personality))), compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, @@ -1963,6 +2018,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, + app_server_client_version: None, session_source: SessionSource::Exec, dynamic_tools: Vec::new(), persist_extended_history: false, @@ -2078,7 +2134,10 @@ async fn turn_context_with_model_updates_model_fields() { let expected_model_info = session .services .models_manager - .get_model_info("gpt-5.1", updated.config.as_ref()) + .get_model_info( + "gpt-5.1", + &updated.config.as_ref().to_models_manager_config(), + ) .await; assert_eq!(updated.config.model.as_deref(), Some("gpt-5.1")); @@ -2166,9 +2225,7 @@ fn success_flag_true_with_no_error_and_content_used() { assert_eq!(expected, got); } -async fn wait_for_thread_rolled_back( - rx: &async_channel::Receiver, -) -> crate::protocol::ThreadRolledBackEvent { +async fn wait_for_thread_rolled_back(rx: &async_channel::Receiver) -> ThreadRolledBackEvent { let deadline = StdDuration::from_secs(2); let start = std::time::Instant::now(); loop { @@ -2210,14 +2267,15 @@ async fn attach_rollout_recorder(session: &Arc) -> PathBuf { config.as_ref(), RolloutRecorderParams::new( ThreadId::default(), - None, + /*forked_from_id*/ None, SessionSource::Exec, - BaseInstructions::default(), + Some(BaseInstructions::default()), + /*developer_instructions*/ None, Vec::new(), EventPersistenceMode::Limited, ), - None, - None, + /*state_db_ctx*/ None, + /*state_builder*/ None, ) .await .expect("create rollout recorder"); @@ -2239,7 +2297,7 @@ fn text_block(s: &str) -> serde_json::Value { } async fn build_test_config(codex_home: &Path) -> Config { - ConfigBuilder::default() + ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.to_path_buf()) .build() .await @@ -2256,11 +2314,11 @@ fn session_telemetry( conversation_id, ModelsManager::get_model_offline_for_tests(config.model.as_deref()).as_str(), model_info.slug.as_str(), - None, + /*account_id*/ None, Some("test@test.com".to_string()), Some(TelemetryAuthMode::Chatgpt), "test_originator".to_string(), - false, + /*log_user_prompts*/ false, "test".to_string(), session_source, ) @@ -2271,7 +2329,10 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati let config = build_test_config(codex_home.path()).await; let config = Arc::new(config); let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref()); - let model_info = ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config); + let model_info = ModelsManager::construct_model_info_offline_for_tests( + model.as_str(), + &config.to_models_manager_config(), + ); let reasoning_effort = config.model_reasoning_effort; let collaboration_mode = CollaborationMode { mode: ModeKind::Default, @@ -2287,13 +2348,14 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), + developer_instructions_override: config.developer_instructions_override.clone(), user_instructions: config.user_instructions.clone(), service_tier: None, personality: config.personality, base_instructions: config .base_instructions .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + .unwrap_or_else(|| Some(model_info.get_model_instructions(config.personality))), compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, @@ -2307,6 +2369,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, + app_server_client_version: None, session_source: SessionSource::Exec, dynamic_tools: Vec::new(), persist_extended_history: false, @@ -2383,7 +2446,7 @@ async fn new_default_turn_uses_config_aware_skills_for_role_overrides() { .skills_manager .skills_for_cwd( &crate::skills_load_input_from_config(&parent_config, Vec::new()), - true, + /*force_reload*/ true, ) .await; let parent_skill = parent_outcome @@ -2489,10 +2552,7 @@ async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_ #[tokio::test] async fn session_update_settings_keeps_runtime_cwds_absolute() { let (session, turn_context) = make_session_and_context().await; - let updated_cwd = turn_context - .cwd - .join("project") - .expect("resolve project dir"); + let updated_cwd = turn_context.cwd.join("project"); std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir"); session @@ -2531,11 +2591,14 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { let models_manager = Arc::new(ModelsManager::new( config.codex_home.clone(), auth_manager.clone(), - None, + /*model_catalog*/ None, CollaborationModesConfig::default(), )); let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref()); - let model_info = ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config); + let model_info = ModelsManager::construct_model_info_offline_for_tests( + model.as_str(), + &config.to_models_manager_config(), + ); let collaboration_mode = CollaborationMode { mode: ModeKind::Default, settings: Settings { @@ -2549,13 +2612,14 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), + developer_instructions_override: config.developer_instructions_override.clone(), user_instructions: config.user_instructions.clone(), service_tier: None, personality: config.personality, base_instructions: config .base_instructions .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + .unwrap_or_else(|| Some(model_info.get_model_instructions(config.personality))), compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, @@ -2569,6 +2633,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, + app_server_client_version: None, session_source: SessionSource::Exec, dynamic_tools: Vec::new(), persist_extended_history: false, @@ -2580,7 +2645,10 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone())); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true)); + let skills_manager = Arc::new(SkillsManager::new( + config.codex_home.clone(), + /*bundled_skills_enabled*/ true, + )); let result = Session::new( session_configuration, Arc::clone(&config), @@ -2596,6 +2664,11 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { mcp_manager, Arc::new(SkillsWatcher::noop()), AgentControl::default(), + Some(Arc::new( + codex_exec_server::Environment::create(/*exec_server_url*/ None) + .await + .expect("create environment"), + )), ) .await; @@ -2618,14 +2691,17 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let models_manager = Arc::new(ModelsManager::new( config.codex_home.clone(), auth_manager.clone(), - None, + /*model_catalog*/ None, CollaborationModesConfig::default(), )); let agent_control = AgentControl::default(); let exec_policy = Arc::new(ExecPolicyManager::default()); let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref()); - let model_info = ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config); + let model_info = ModelsManager::construct_model_info_offline_for_tests( + model.as_str(), + &config.to_models_manager_config(), + ); let reasoning_effort = config.model_reasoning_effort; let collaboration_mode = CollaborationMode { mode: ModeKind::Default, @@ -2640,13 +2716,14 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), + developer_instructions_override: config.developer_instructions_override.clone(), user_instructions: config.user_instructions.clone(), service_tier: None, personality: config.personality, base_instructions: config .base_instructions .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + .unwrap_or_else(|| Some(model_info.get_model_instructions(config.personality))), compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, @@ -2660,6 +2737,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, + app_server_client_version: None, session_source: SessionSource::Exec, dynamic_tools: Vec::new(), persist_extended_history: false, @@ -2669,7 +2747,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let per_turn_config = Session::build_per_turn_config(&session_configuration); let model_info = ModelsManager::construct_model_info_offline_for_tests( session_configuration.collaboration_mode.model(), - &per_turn_config, + &per_turn_config.to_models_manager_config(), ); let session_telemetry = session_telemetry( conversation_id, @@ -2681,21 +2759,22 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let state = SessionState::new(session_configuration.clone()); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone())); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true)); + let skills_manager = Arc::new(SkillsManager::new( + config.codex_home.clone(), + /*bundled_skills_enabled*/ true, + )); let network_approval = Arc::new(NetworkApprovalService::default()); let environment = Arc::new( - codex_exec_server::Environment::create(None) + codex_exec_server::Environment::create(/*exec_server_url*/ None) .await .expect("create environment"), ); let skills_watcher = Arc::new(SkillsWatcher::noop()); let services = SessionServices { - mcp_connection_manager: Arc::new(RwLock::new( - McpConnectionManager::new_mcp_connection_manager_for_tests( - &config.permissions.approval_policy, - ), - )), + mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized( + &config.permissions.approval_policy, + ))), mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), unified_exec_manager: UnifiedExecProcessManager::new( config.background_terminal_max_timeout, @@ -2720,7 +2799,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { session_telemetry: session_telemetry.clone(), models_manager: Arc::clone(&models_manager), tool_approvals: Mutex::new(ApprovalStore::default()), - execve_session_approvals: RwLock::new(HashMap::new()), skills_manager, plugins_manager, mcp_manager, @@ -2732,6 +2810,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { model_client: ModelClient::new( Some(auth_manager.clone()), conversation_id, + /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), session_configuration.provider.clone(), session_configuration.session_source.clone(), config.model_verbosity, @@ -2742,7 +2821,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { code_mode_service: crate::tools::code_mode::CodeModeService::new( config.js_repl_node_path.clone(), ), - environment: Arc::clone(&environment), + environment: Some(Arc::clone(&environment)), }; let js_repl = Arc::new(JsReplHandle::with_node_path( config.js_repl_node_path.clone(), @@ -2768,13 +2847,14 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { per_turn_config, model_info, &models_manager, - None, - environment, + /*network*/ None, + Some(environment), "turn_id".to_string(), Arc::clone(&js_repl), skills_outcome, ); + let (mailbox, mailbox_rx) = crate::agent::Mailbox::new(); let session = Session { conversation_id, tx_event, @@ -2785,6 +2865,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), + mailbox, + mailbox_rx: Mutex::new(mailbox_rx), idle_pending_input: Mutex::new(Vec::new()), guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(), services, @@ -2825,15 +2907,13 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() Arc::get_mut(&mut turn_context) .expect("single turn context ref") .approval_policy - .set(crate::protocol::AskForApproval::Granular( - crate::protocol::GranularApprovalConfig { - sandbox_approval: true, - rules: true, - skill_approval: true, - request_permissions: true, - mcp_elicitations: true, - }, - )) + .set(AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: true, + rules: true, + skill_approval: true, + request_permissions: true, + mcp_elicitations: true, + })) .expect("test setup should allow updating approval policy"); let session = Arc::new(session); @@ -2900,15 +2980,13 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req Arc::get_mut(&mut turn_context) .expect("single turn context ref") .approval_policy - .set(crate::protocol::AskForApproval::Granular( - crate::protocol::GranularApprovalConfig { - sandbox_approval: true, - rules: true, - skill_approval: true, - request_permissions: false, - mcp_elicitations: true, - }, - )) + .set(AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: true, + rules: true, + skill_approval: true, + request_permissions: false, + mcp_elicitations: true, + })) .expect("test setup should allow updating approval policy"); let session = Arc::new(session); @@ -3131,7 +3209,7 @@ async fn user_turn_updates_approvals_reviewer() { }], cwd: config.cwd.to_path_buf(), approval_policy: config.permissions.approval_policy.value(), - approvals_reviewer: Some(crate::config::types::ApprovalsReviewer::GuardianSubagent), + approvals_reviewer: Some(codex_config::types::ApprovalsReviewer::GuardianSubagent), sandbox_policy: config.permissions.sandbox_policy.get().clone(), model: turn_context.model_info.slug.clone(), effort: config.model_reasoning_effort, @@ -3147,7 +3225,7 @@ async fn user_turn_updates_approvals_reviewer() { let state = session.state.lock().await; assert_eq!( state.session_configuration.approvals_reviewer, - crate::config::types::ApprovalsReviewer::GuardianSubagent + codex_config::types::ApprovalsReviewer::GuardianSubagent ); } @@ -3157,7 +3235,6 @@ async fn spawn_task_turn_span_inherits_dispatch_trace_context() { captured_trace: Arc>>, } - #[async_trait::async_trait] impl SessionTask for TraceCaptureTask { fn kind(&self) -> TaskKind { TaskKind::Regular @@ -3456,14 +3533,17 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( let models_manager = Arc::new(ModelsManager::new( config.codex_home.clone(), auth_manager.clone(), - None, + /*model_catalog*/ None, CollaborationModesConfig::default(), )); let agent_control = AgentControl::default(); let exec_policy = Arc::new(ExecPolicyManager::default()); let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref()); - let model_info = ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config); + let model_info = ModelsManager::construct_model_info_offline_for_tests( + model.as_str(), + &config.to_models_manager_config(), + ); let reasoning_effort = config.model_reasoning_effort; let collaboration_mode = CollaborationMode { mode: ModeKind::Default, @@ -3478,13 +3558,14 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), + developer_instructions_override: config.developer_instructions_override.clone(), user_instructions: config.user_instructions.clone(), service_tier: None, personality: config.personality, base_instructions: config .base_instructions .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + .unwrap_or_else(|| Some(model_info.get_model_instructions(config.personality))), compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, @@ -3498,6 +3579,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( original_config_do_not_use: Arc::clone(&config), metrics_service_name: None, app_server_client_name: None, + app_server_client_version: None, session_source: SessionSource::Exec, dynamic_tools, persist_extended_history: false, @@ -3507,7 +3589,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( let per_turn_config = Session::build_per_turn_config(&session_configuration); let model_info = ModelsManager::construct_model_info_offline_for_tests( session_configuration.collaboration_mode.model(), - &per_turn_config, + &per_turn_config.to_models_manager_config(), ); let session_telemetry = session_telemetry( conversation_id, @@ -3519,21 +3601,22 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( let state = SessionState::new(session_configuration.clone()); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone())); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true)); + let skills_manager = Arc::new(SkillsManager::new( + config.codex_home.clone(), + /*bundled_skills_enabled*/ true, + )); let network_approval = Arc::new(NetworkApprovalService::default()); let environment = Arc::new( - codex_exec_server::Environment::create(None) + codex_exec_server::Environment::create(/*exec_server_url*/ None) .await .expect("create environment"), ); let skills_watcher = Arc::new(SkillsWatcher::noop()); let services = SessionServices { - mcp_connection_manager: Arc::new(RwLock::new( - McpConnectionManager::new_mcp_connection_manager_for_tests( - &config.permissions.approval_policy, - ), - )), + mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized( + &config.permissions.approval_policy, + ))), mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), unified_exec_manager: UnifiedExecProcessManager::new( config.background_terminal_max_timeout, @@ -3558,7 +3641,6 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( session_telemetry: session_telemetry.clone(), models_manager: Arc::clone(&models_manager), tool_approvals: Mutex::new(ApprovalStore::default()), - execve_session_approvals: RwLock::new(HashMap::new()), skills_manager, plugins_manager, mcp_manager, @@ -3570,6 +3652,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), conversation_id, + /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), session_configuration.provider.clone(), session_configuration.session_source.clone(), config.model_verbosity, @@ -3580,7 +3663,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( code_mode_service: crate::tools::code_mode::CodeModeService::new( config.js_repl_node_path.clone(), ), - environment: Arc::clone(&environment), + environment: Some(Arc::clone(&environment)), }; let js_repl = Arc::new(JsReplHandle::with_node_path( config.js_repl_node_path.clone(), @@ -3606,13 +3689,14 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( per_turn_config, model_info, &models_manager, - None, - environment, + /*network*/ None, + Some(environment), "turn_id".to_string(), Arc::clone(&js_repl), skills_outcome, )); + let (mailbox, mailbox_rx) = crate::agent::Mailbox::new(); let session = Arc::new(Session { conversation_id, tx_event, @@ -3623,6 +3707,8 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( pending_mcp_server_refresh_config: Mutex::new(None), conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), + mailbox, + mailbox_rx: Mutex::new(mailbox_rx), idle_pending_input: Mutex::new(Vec::new()), guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(), services, @@ -3716,7 +3802,8 @@ async fn record_model_warning_appends_user_message() { #[tokio::test] async fn spawn_task_does_not_update_previous_turn_settings_for_non_run_turn_tasks() { let (sess, tc, _rx) = make_session_and_context_with_rx().await; - sess.set_previous_turn_settings(None).await; + sess.set_previous_turn_settings(/*previous_turn_settings*/ None) + .await; let input = vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), @@ -3751,15 +3838,28 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes( let mut requirements = config.config_layer_stack.requirements().clone(); requirements.network = Some(Sourced::new( NetworkConstraints { - allowed_domains: Some(vec!["api.example.com".to_string()]), - denied_domains: Some(vec!["blocked.example.com".to_string()]), + domains: Some(NetworkDomainPermissionsToml { + entries: std::collections::BTreeMap::from([ + ( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "blocked.example.com".to_string(), + NetworkDomainPermissionToml::Deny, + ), + ]), + }), ..Default::default() }, RequirementSource::CloudRequirements, )); let layers = config .config_layer_stack - .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true) + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) .into_iter() .cloned() .collect(); @@ -3776,17 +3876,9 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes( .build_settings_update_items(Some(&reference_context_item), ¤t_context) .await; - let environment_update = update_items - .iter() - .find_map(|item| match item { - ResponseItem::Message { role, content, .. } if role == "user" => { - let [ContentItem::InputText { text }] = content.as_slice() else { - return None; - }; - text.contains("").then_some(text) - } - _ => None, - }) + let environment_update = user_input_texts(&update_items) + .into_iter() + .find(|text| text.contains("")) .expect("environment update item should be emitted"); assert!(environment_update.contains("")); assert!(environment_update.contains("api.example.com")); @@ -3811,22 +3903,43 @@ async fn build_settings_update_items_emits_environment_item_for_time_changes() { .build_settings_update_items(Some(&reference_context_item), ¤t_context) .await; - let environment_update = update_items - .iter() - .find_map(|item| match item { - ResponseItem::Message { role, content, .. } if role == "user" => { - let [ContentItem::InputText { text }] = content.as_slice() else { - return None; - }; - text.contains("").then_some(text) - } - _ => None, - }) + let environment_update = user_input_texts(&update_items) + .into_iter() + .find(|text| text.contains("")) .expect("environment update item should be emitted"); assert!(environment_update.contains("2026-02-27")); assert!(environment_update.contains("Europe/Berlin")); } +#[tokio::test] +async fn build_settings_update_items_omits_environment_item_when_disabled() { + let (session, previous_context) = make_session_and_context().await; + let previous_context = Arc::new(previous_context); + let mut current_context = previous_context + .with_model( + previous_context.model_info.slug.clone(), + &session.services.models_manager, + ) + .await; + let mut config = (*current_context.config).clone(); + config.include_environment_context = false; + current_context.config = Arc::new(config); + current_context.current_date = Some("2026-02-27".to_string()); + + let reference_context_item = previous_context.to_turn_context_item(); + let update_items = session + .build_settings_update_items(Some(&reference_context_item), ¤t_context) + .await; + + let user_texts = user_input_texts(&update_items); + assert!( + !user_texts + .iter() + .any(|text| text.contains("")), + "did not expect environment context updates when disabled, got {user_texts:?}" + ); +} + #[tokio::test] async fn build_settings_update_items_emits_realtime_start_when_session_becomes_live() { let (session, previous_context) = make_session_and_context().await; @@ -3956,7 +4069,7 @@ async fn build_initial_context_omits_default_image_save_location_with_image_hist revised_prompt: Some("a tiny blue square".to_string()), result: "Zm9v".to_string(), }], - None, + /*reference_context_item*/ None, ) .await; @@ -4010,7 +4123,7 @@ async fn handle_output_item_done_records_image_save_history_message() { tool_runtime: test_tool_runtime(Arc::clone(&session), Arc::clone(&turn_context)), cancellation_token: CancellationToken::new(), }; - handle_output_item_done(&mut ctx, item.clone(), None) + handle_output_item_done(&mut ctx, item.clone(), /*previously_active_item*/ None) .await .expect("image generation item should succeed"); @@ -4023,18 +4136,13 @@ async fn handle_output_item_done_records_image_save_history_message() { let image_output_dir = image_output_path .parent() .expect("generated image path should have a parent"); - let save_message: ResponseItem = DeveloperInstructions::new(format!( - "Generated images are saved to {} as {} by default.", + let image_message: ResponseItem = DeveloperInstructions::new(format!( + "Generated images are saved to {} as {} by default.\nIf you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.", image_output_dir.display(), image_output_path.display(), )) .into(); - let copy_message: ResponseItem = DeveloperInstructions::new( - "If you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it." - .to_string(), - ) - .into(); - assert_eq!(history.raw_items(), &[save_message, copy_message, item]); + assert_eq!(history.raw_items(), &[image_message, item]); assert_eq!( std::fs::read(&expected_saved_path).expect("saved file"), b"foo" @@ -4067,7 +4175,7 @@ async fn handle_output_item_done_skips_image_save_message_when_save_fails() { tool_runtime: test_tool_runtime(Arc::clone(&session), Arc::clone(&turn_context)), cancellation_token: CancellationToken::new(), }; - handle_output_item_done(&mut ctx, item.clone(), None) + handle_output_item_done(&mut ctx, item.clone(), /*previously_active_item*/ None) .await .expect("image generation item should still complete"); @@ -4159,10 +4267,13 @@ async fn record_context_updates_and_set_reference_context_item_reinjects_full_co .await; { let mut state = session.state.lock().await; - state.set_reference_context_item(None); + state.set_reference_context_item(/*item*/ None); } session - .replace_history(vec![compacted_summary.clone()], None) + .replace_history( + vec![compacted_summary.clone()], + /*reference_context_item*/ None, + ) .await; session @@ -4197,14 +4308,15 @@ async fn record_context_updates_and_set_reference_context_item_persists_baseline config.as_ref(), RolloutRecorderParams::new( ThreadId::default(), - None, + /*forked_from_id*/ None, SessionSource::Exec, - BaseInstructions::default(), + Some(BaseInstructions::default()), + /*developer_instructions*/ None, Vec::new(), EventPersistenceMode::Limited, ), - None, - None, + /*state_db_ctx*/ None, + /*state_builder*/ None, ) .await .expect("create rollout recorder"); @@ -4294,14 +4406,15 @@ async fn record_context_updates_and_set_reference_context_item_persists_full_rei config.as_ref(), RolloutRecorderParams::new( ThreadId::default(), - None, + /*forked_from_id*/ None, SessionSource::Exec, - BaseInstructions::default(), + Some(BaseInstructions::default()), + /*developer_instructions*/ None, Vec::new(), EventPersistenceMode::Limited, ), - None, - None, + /*state_db_ctx*/ None, + /*state_builder*/ None, ) .await .expect("create rollout recorder"); @@ -4323,7 +4436,7 @@ async fn record_context_updates_and_set_reference_context_item_persists_full_rei .await; { let mut state = session.state.lock().await; - state.set_reference_context_item(None); + state.set_reference_context_item(/*item*/ None); } session @@ -4362,7 +4475,7 @@ async fn run_user_shell_command_does_not_set_reference_context_item() { let (session, _turn_context, rx) = make_session_and_context_with_rx().await; { let mut state = session.state.lock().await; - state.set_reference_context_item(None); + state.set_reference_context_item(/*item*/ None); } handlers::run_user_shell_command(&session, "sub-id".to_string(), "echo shell".to_string()) @@ -4393,7 +4506,6 @@ struct NeverEndingTask { listen_to_cancellation_token: bool, } -#[async_trait::async_trait] impl SessionTask for NeverEndingTask { fn kind(&self) -> TaskKind { self.kind @@ -4515,7 +4627,8 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() .await .expect("inject pending input into active turn"); - sess.on_task_finished(Arc::clone(&tc), None).await; + sess.on_task_finished(Arc::clone(&tc), /*last_agent_message*/ None) + .await; let history = sess.clone_history().await; let expected = ResponseItem::Message { @@ -4594,6 +4707,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + .. }) if turn_id == tc.sub_id )); } @@ -4607,7 +4721,7 @@ async fn steer_input_requires_active_turn() { }]; let err = sess - .steer_input(input, None) + .steer_input(input, /*expected_turn_id*/ None) .await .expect_err("steering without active turn should fail"); @@ -4800,6 +4914,214 @@ async fn queued_response_items_for_next_turn_move_into_next_active_turn() { assert_eq!(sess.get_pending_input().await, vec![queued_item]); } +#[tokio::test] +async fn queue_only_mailbox_mail_waits_for_next_turn_after_answer_boundary() { + let (sess, tc, _rx) = make_session_and_context_with_rx().await; + let communication = InterAgentCommunication::new( + AgentPath::try_from("/root/worker").expect("worker path should parse"), + AgentPath::root(), + Vec::new(), + "late queue-only update".to_string(), + /*trigger_turn*/ false, + ); + sess.spawn_task( + Arc::clone(&tc), + Vec::new(), + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: true, + }, + ) + .await; + + sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await; + sess.enqueue_mailbox_communication(communication.clone()); + + assert!( + !sess.has_pending_input().await, + "queue-only mailbox mail should stay buffered once the current turn emitted its answer" + ); + assert_eq!(sess.get_pending_input().await, Vec::new()); + + sess.abort_all_tasks(TurnAbortReason::Replaced).await; + + assert_eq!( + sess.get_pending_input().await, + vec![communication.to_response_input_item()], + ); +} + +#[tokio::test] +async fn trigger_turn_mailbox_mail_waits_for_next_turn_after_answer_boundary() { + let (sess, tc, _rx) = make_session_and_context_with_rx().await; + sess.spawn_task( + Arc::clone(&tc), + Vec::new(), + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: true, + }, + ) + .await; + + sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await; + sess.enqueue_mailbox_communication(InterAgentCommunication::new( + AgentPath::try_from("/root/worker").expect("worker path should parse"), + AgentPath::root(), + Vec::new(), + "late trigger update".to_string(), + /*trigger_turn*/ true, + )); + + assert!( + !sess.has_pending_input().await, + "trigger-turn mailbox mail should not extend the current turn after its answer boundary" + ); + + sess.abort_all_tasks(TurnAbortReason::Replaced).await; + + assert!(sess.has_trigger_turn_mailbox_items().await); +} + +#[tokio::test] +async fn steered_input_reopens_mailbox_delivery_for_current_turn() { + let (sess, tc, _rx) = make_session_and_context_with_rx().await; + let communication = InterAgentCommunication::new( + AgentPath::try_from("/root/worker").expect("worker path should parse"), + AgentPath::root(), + Vec::new(), + "queued child update".to_string(), + /*trigger_turn*/ false, + ); + sess.spawn_task( + Arc::clone(&tc), + Vec::new(), + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: true, + }, + ) + .await; + + sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await; + sess.enqueue_mailbox_communication(communication.clone()); + sess.steer_input( + vec![UserInput::Text { + text: "follow up".to_string(), + text_elements: Vec::new(), + }], + Some(&tc.sub_id), + ) + .await + .expect("steered input should be accepted"); + + assert_eq!( + sess.get_pending_input().await, + vec![ + ResponseInputItem::from(vec![UserInput::Text { + text: "follow up".to_string(), + text_elements: Vec::new(), + }]), + communication.to_response_input_item(), + ], + ); +} + +#[tokio::test] +async fn stale_defer_mailbox_delivery_does_not_override_steered_input() { + let (sess, tc, _rx) = make_session_and_context_with_rx().await; + let communication = InterAgentCommunication::new( + AgentPath::try_from("/root/worker").expect("worker path should parse"), + AgentPath::root(), + Vec::new(), + "queued child update".to_string(), + /*trigger_turn*/ false, + ); + sess.spawn_task( + Arc::clone(&tc), + Vec::new(), + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: true, + }, + ) + .await; + + sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await; + sess.enqueue_mailbox_communication(communication.clone()); + sess.steer_input( + vec![UserInput::Text { + text: "follow up".to_string(), + text_elements: Vec::new(), + }], + Some(&tc.sub_id), + ) + .await + .expect("steered input should be accepted"); + + sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await; + + assert_eq!( + sess.get_pending_input().await, + vec![ + ResponseInputItem::from(vec![UserInput::Text { + text: "follow up".to_string(), + text_elements: Vec::new(), + }]), + communication.to_response_input_item(), + ], + ); +} + +#[tokio::test] +async fn tool_calls_reopen_mailbox_delivery_for_current_turn() { + let (sess, tc, _rx) = make_session_and_context_with_rx().await; + let communication = InterAgentCommunication::new( + AgentPath::try_from("/root/worker").expect("worker path should parse"), + AgentPath::root(), + Vec::new(), + "queued child update".to_string(), + /*trigger_turn*/ false, + ); + sess.spawn_task( + Arc::clone(&tc), + Vec::new(), + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: true, + }, + ) + .await; + + sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await; + sess.enqueue_mailbox_communication(communication.clone()); + + let item = ResponseItem::FunctionCall { + id: None, + name: "test_tool".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: "call-1".to_string(), + }; + let mut ctx = HandleOutputCtx { + sess: Arc::clone(&sess), + turn_context: Arc::clone(&tc), + tool_runtime: test_tool_runtime(Arc::clone(&sess), Arc::clone(&tc)), + cancellation_token: CancellationToken::new(), + }; + + let output = handle_output_item_done(&mut ctx, item, /*previously_active_item*/ None) + .await + .expect("tool call should be handled"); + + assert!(output.needs_follow_up); + assert!(output.tool_future.is_some()); + assert_eq!( + sess.get_pending_input().await, + vec![communication.to_response_input_item()], + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn abort_review_task_emits_exited_then_aborted_and_records_history() { let (sess, tc, rx) = make_session_and_context_with_rx().await; @@ -5103,10 +5425,12 @@ async fn sample_rollout( #[tokio::test] async fn rejects_escalated_permissions_when_policy_not_on_request() { use crate::exec::ExecParams; - use crate::protocol::AskForApproval; - use crate::protocol::SandboxPolicy; + use crate::exec_policy::ExecApprovalRequest; use crate::sandboxing::SandboxPermissions; + use crate::tools::sandboxing::ExecApprovalRequirement; use crate::turn_diff_tracker::TurnDiffTracker; + use codex_protocol::protocol::AskForApproval; + use codex_protocol::protocol::SandboxPolicy; use std::collections::HashMap; let (session, mut turn_context_raw) = make_session_and_context().await; @@ -5149,23 +5473,6 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { arg0: None, }; - let params2 = ExecParams { - sandbox_permissions: SandboxPermissions::UseDefault, - command: params.command.clone(), - cwd: params.cwd.clone(), - expiration: timeout_ms.into(), - capture_policy: ExecCapturePolicy::ShellTool, - env: HashMap::new(), - network: None, - windows_sandbox_level: turn_context.windows_sandbox_level, - windows_sandbox_private_desktop: turn_context - .config - .permissions - .windows_sandbox_private_desktop, - justification: params.justification.clone(), - arg0: None, - }; - let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); let tool_name = "shell"; @@ -5203,9 +5510,11 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { ); pretty_assertions::assert_eq!(output, expected); + pretty_assertions::assert_eq!(session.granted_turn_permissions().await, None); - // Now retry the same command WITHOUT escalated permissions; should succeed. - // Force DangerFullAccess to avoid platform sandbox dependencies in tests. + // The rejection should not poison the non-escalated path for the same + // command. Force DangerFullAccess so this check stays focused on approval + // policy rather than platform-specific sandbox behavior. let turn_context_mut = Arc::get_mut(&mut turn_context).expect("unique turn context Arc"); turn_context_mut .sandbox_policy @@ -5216,51 +5525,28 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { turn_context_mut.network_sandbox_policy = NetworkSandboxPolicy::from(turn_context_mut.sandbox_policy.get()); - let resp2 = handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - tracker: Arc::clone(&turn_diff_tracker), - call_id: "test-call-2".to_string(), - tool_name: tool_name.to_string(), - tool_namespace: None, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "command": params2.command.clone(), - "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), - "timeout_ms": params2.expiration.timeout_ms(), - "sandbox_permissions": params2.sandbox_permissions, - "justification": params2.justification.clone(), - }) - .to_string(), - }, + let exec_approval_requirement = session + .services + .exec_policy + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: ¶ms.command, + approval_policy: turn_context.approval_policy.value(), + sandbox_policy: turn_context.sandbox_policy.get(), + file_system_sandbox_policy: &turn_context.file_system_sandbox_policy, + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, }) .await; - - let output = expect_text_tool_output(&resp2.expect("expected Ok result")); - - #[derive(Deserialize, PartialEq, Eq, Debug)] - struct ResponseExecMetadata { - exit_code: i32, - } - - #[derive(Deserialize)] - struct ResponseExecOutput { - output: String, - metadata: ResponseExecMetadata, - } - - let exec_output: ResponseExecOutput = - serde_json::from_str(&output).expect("valid exec output json"); - - pretty_assertions::assert_eq!(exec_output.metadata, ResponseExecMetadata { exit_code: 0 }); - assert!(exec_output.output.contains("hi")); + assert!(matches!( + exec_approval_requirement, + ExecApprovalRequirement::Skip { .. } + )); } #[tokio::test] async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() { - use crate::protocol::AskForApproval; use crate::sandboxing::SandboxPermissions; use crate::turn_diff_tracker::TurnDiffTracker; + use codex_protocol::protocol::AskForApproval; let (session, mut turn_context_raw) = make_session_and_context().await; turn_context_raw diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index 54f7405be2..4f60c2f28e 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -7,11 +7,11 @@ use crate::exec::ExecCapturePolicy; use crate::exec::ExecParams; use crate::exec_policy::ExecPolicyManager; use crate::guardian::GUARDIAN_REVIEWER_NAME; -use crate::protocol::AskForApproval; use crate::sandboxing::SandboxPermissions; use crate::tools::context::FunctionToolOutput; use crate::turn_diff_tracker::TurnDiffTracker; use codex_app_server_protocol::ConfigLayerSource; +use codex_exec_server::EnvironmentManager; use codex_execpolicy::Decision; use codex_execpolicy::Evaluation; use codex_execpolicy::RuleMatch; @@ -23,6 +23,7 @@ use codex_protocol::models::ResponseItem; use codex_protocol::models::function_call_output_content_items_to_text; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::AskForApproval; use core_test_support::PathExt; use core_test_support::TempDirExt; use core_test_support::codex_linux_sandbox_exe_or_skip; @@ -159,7 +160,6 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid enabled: Some(true), }), file_system: None, - macos: None, }, "justification": params.justification.clone(), }) @@ -233,7 +233,7 @@ async fn guardian_allows_unified_exec_additional_permissions_requests_past_polic assert_eq!( output, - "missing `additional_permissions`; provide at least one of `network`, `file_system`, or `macos` when using `with_additional_permissions`" + "missing `additional_permissions`; provide at least one of `network` or `file_system` when using `with_additional_permissions`" ); } @@ -425,11 +425,14 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { let models_manager = Arc::new(ModelsManager::new( config.codex_home.clone(), auth_manager.clone(), - None, + /*model_catalog*/ None, CollaborationModesConfig::default(), )); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone())); - let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true)); + let skills_manager = Arc::new(SkillsManager::new( + config.codex_home.clone(), + /*bundled_skills_enabled*/ true, + )); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); let skills_watcher = Arc::new(SkillsWatcher::noop()); @@ -437,6 +440,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { config, auth_manager, models_manager, + environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)), skills_manager, plugins_manager, mcp_manager, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 9fa7069118..9727cc208a 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -2,31 +2,32 @@ use crate::agent::AgentStatus; use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; -use crate::error::CodexErr; -use crate::error::Result as CodexResult; use crate::file_watcher::WatchRegistration; -use crate::protocol::Event; -use crate::protocol::Op; -use crate::protocol::Submission; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ServiceTier; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result as CodexResult; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::Submission; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::W3cTraceContext; use codex_protocol::user_input::UserInput; +use rmcp::model::ReadResourceRequestParams; use std::path::PathBuf; use tokio::sync::Mutex; use tokio::sync::watch; -use crate::state_db::StateDbHandle; +use codex_rollout::state_db::StateDbHandle; #[derive(Clone, Debug)] pub struct ThreadConfigSnapshot { @@ -74,6 +75,16 @@ impl CodexThread { self.codex.shutdown_and_wait().await } + #[doc(hidden)] + pub async fn ensure_rollout_materialized(&self) { + self.codex.session.ensure_rollout_materialized().await; + } + + #[doc(hidden)] + pub async fn flush_rollout(&self) { + self.codex.session.flush_rollout().await; + } + pub async fn submit_with_trace( &self, op: Op, @@ -90,12 +101,13 @@ impl CodexThread { self.codex.steer_input(input, expected_turn_id).await } - pub async fn set_app_server_client_name( + pub async fn set_app_server_client_info( &self, app_server_client_name: Option, + app_server_client_version: Option, ) -> ConstraintResult<()> { self.codex - .set_app_server_client_name(app_server_client_name) + .set_app_server_client_info(app_server_client_name, app_server_client_version) .await } @@ -170,10 +182,7 @@ impl CodexThread { .session .queue_response_items_for_next_turn(items) .await; - self.codex - .session - .ensure_task_for_queued_response_items() - .await; + self.codex.session.maybe_start_turn_for_pending_work().await; } Ok(submission_id) @@ -191,6 +200,26 @@ impl CodexThread { self.codex.thread_config_snapshot().await } + pub async fn read_mcp_resource( + &self, + server: &str, + uri: &str, + ) -> anyhow::Result { + let result = self + .codex + .session + .read_resource( + server, + ReadResourceRequestParams { + meta: None, + uri: uri.to_string(), + }, + ) + .await?; + + Ok(serde_json::to_value(result)?) + } + pub fn enabled(&self, feature: Feature) -> bool { self.codex.enabled(feature) } diff --git a/codex-rs/core/src/command_canonicalization.rs b/codex-rs/core/src/command_canonicalization.rs index 3457fa2f63..b88a79375d 100644 --- a/codex-rs/core/src/command_canonicalization.rs +++ b/codex-rs/core/src/command_canonicalization.rs @@ -1,6 +1,6 @@ -use crate::bash::extract_bash_command; -use crate::bash::parse_shell_lc_plain_commands; -use crate::powershell::extract_powershell_command; +use codex_shell_command::bash::extract_bash_command; +use codex_shell_command::bash::parse_shell_lc_plain_commands; +use codex_shell_command::powershell::extract_powershell_command; const CANONICAL_BASH_SCRIPT_PREFIX: &str = "__codex_shell_script__"; const CANONICAL_POWERSHELL_SCRIPT_PREFIX: &str = "__codex_powershell_script__"; diff --git a/codex-rs/core/src/commit_attribution_tests.rs b/codex-rs/core/src/commit_attribution_tests.rs index be7661a604..8b2ce27e1f 100644 --- a/codex-rs/core/src/commit_attribution_tests.rs +++ b/codex-rs/core/src/commit_attribution_tests.rs @@ -11,7 +11,7 @@ fn blank_attribution_disables_trailer_prompt() { #[test] fn default_attribution_uses_codex_trailer() { assert_eq!( - build_commit_message_trailer(None).as_deref(), + build_commit_message_trailer(/*config_attribution*/ None).as_deref(), Some("Co-authored-by: Codex ") ); } @@ -19,7 +19,7 @@ fn default_attribution_uses_codex_trailer() { #[test] fn resolve_value_handles_default_custom_and_blank() { assert_eq!( - resolve_attribution_value(None), + resolve_attribution_value(/*config_attribution*/ None), Some("Codex ".to_string()) ); assert_eq!( diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 82fd5c15e3..8300dc650c 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -1,6 +1,5 @@ use std::sync::Arc; -use crate::ModelProviderInfo; use crate::Prompt; use crate::client::ModelClientSession; use crate::client_common::ResponseEvent; @@ -9,18 +8,19 @@ use crate::codex::PreviousTurnSettings; use crate::codex::Session; use crate::codex::TurnContext; use crate::codex::get_last_assistant_message_from_turn; -use crate::error::CodexErr; -use crate::error::Result as CodexResult; -use crate::protocol::CompactedItem; -use crate::protocol::EventMsg; -use crate::protocol::TurnStartedEvent; -use crate::protocol::WarningEvent; use crate::util::backoff; +use codex_model_provider_info::ModelProviderInfo; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result as CodexResult; use codex_protocol::items::ContextCompactionItem; use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::CompactedItem; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::TurnStartedEvent; +use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::approx_token_count; @@ -74,6 +74,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); @@ -220,6 +221,7 @@ async fn run_compact_task_inner( }; sess.replace_compacted_history(new_history, reference_context_item, compacted_item) .await; + client_session.reset_websocket_session(); sess.recompute_token_usage(&turn_context).await; sess.emit_turn_item_completed(&turn_context, compaction_item) diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 28be574318..640ca3c1f8 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -11,15 +11,15 @@ use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::context_manager::estimate_response_item_model_visible_bytes; use crate::context_manager::is_codex_generated_item; -use crate::error::CodexErr; -use crate::error::Result as CodexResult; -use crate::protocol::CompactedItem; -use crate::protocol::EventMsg; -use crate::protocol::TurnStartedEvent; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result as CodexResult; use codex_protocol::items::ContextCompactionItem; use codex_protocol::items::TurnItem; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::CompactedItem; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::TurnStartedEvent; use futures::TryFutureExt; use tokio_util::sync::CancellationToken; use tracing::error; @@ -40,6 +40,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, }); @@ -75,10 +76,16 @@ async fn run_remote_compact_task_inner_impl( .await; let mut history = sess.clone_history().await; let base_instructions = sess.get_base_instructions().await; + let token_count_base_instructions = + base_instructions + .clone() + .unwrap_or_else(|| BaseInstructions { + text: String::new(), + }); let deleted_items = trim_function_call_history_to_fit_context_window( &mut history, turn_context.as_ref(), - &base_instructions, + &token_count_base_instructions, ); if deleted_items > 0 { info!( @@ -126,8 +133,13 @@ async fn run_remote_compact_task_inner_impl( ) .or_else(|err| async { let total_usage_breakdown = sess.get_total_token_usage_breakdown().await; + let base_instruction_text = prompt + .base_instructions + .as_ref() + .map(|base_instructions| base_instructions.text.as_str()) + .unwrap_or(""); let compact_request_log_data = - build_compact_request_log_data(&prompt.input, &prompt.base_instructions.text); + build_compact_request_log_data(&prompt.input, base_instruction_text); log_remote_compact_failure( turn_context, &compact_request_log_data, diff --git a/codex-rs/core/src/compact_tests.rs b/codex-rs/core/src/compact_tests.rs index 92e889d647..cecf9ce9d7 100644 --- a/codex-rs/core/src/compact_tests.rs +++ b/codex-rs/core/src/compact_tests.rs @@ -216,8 +216,11 @@ async fn process_compacted_history_replaces_developer_messages() { phase: None, }, ]; - let (refreshed, mut expected) = - process_compacted_history_with_test_session(compacted_history, None).await; + let (refreshed, mut expected) = process_compacted_history_with_test_session( + compacted_history, + /*previous_turn_settings*/ None, + ) + .await; expected.push(ResponseItem::Message { id: None, role: "user".to_string(), @@ -241,8 +244,11 @@ async fn process_compacted_history_reinjects_full_initial_context() { end_turn: None, phase: None, }]; - let (refreshed, mut expected) = - process_compacted_history_with_test_session(compacted_history, None).await; + let (refreshed, mut expected) = process_compacted_history_with_test_session( + compacted_history, + /*previous_turn_settings*/ None, + ) + .await; expected.push(ResponseItem::Message { id: None, role: "user".to_string(), @@ -317,8 +323,11 @@ keep me updated phase: None, }, ]; - let (refreshed, mut expected) = - process_compacted_history_with_test_session(compacted_history, None).await; + let (refreshed, mut expected) = process_compacted_history_with_test_session( + compacted_history, + /*previous_turn_settings*/ None, + ) + .await; expected.push(ResponseItem::Message { id: None, role: "user".to_string(), @@ -363,8 +372,11 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message }, ]; - let (refreshed, initial_context) = - process_compacted_history_with_test_session(compacted_history, None).await; + let (refreshed, initial_context) = process_compacted_history_with_test_session( + compacted_history, + /*previous_turn_settings*/ None, + ) + .await; let mut expected = vec![ ResponseItem::Message { id: None, diff --git a/codex-rs/core/src/config/agent_roles.rs b/codex-rs/core/src/config/agent_roles.rs index c527435e92..24d26ebf47 100644 --- a/codex-rs/core/src/config/agent_roles.rs +++ b/codex-rs/core/src/config/agent_roles.rs @@ -1,9 +1,9 @@ use super::AgentRoleConfig; -use super::AgentRoleToml; -use super::AgentsToml; -use super::ConfigToml; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigLayerStackOrdering; +use codex_config::config_toml::AgentRoleToml; +use codex_config::config_toml::AgentsToml; +use codex_config::config_toml::ConfigToml; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; use serde::Deserialize; diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 4e191a6e16..ae8f8615d4 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1,28 +1,57 @@ use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; use crate::config::edit::apply_blocking; -use crate::config::types::ApprovalsReviewer; -use crate::config::types::BundledSkillsConfig; -use crate::config::types::FeedbackConfigToml; -use crate::config::types::HistoryPersistence; -use crate::config::types::McpServerTransportConfig; -use crate::config::types::MemoriesConfig; -use crate::config::types::MemoriesToml; -use crate::config::types::ModelAvailabilityNuxConfig; -use crate::config::types::NotificationMethod; -use crate::config::types::Notifications; -use crate::config::types::ToolSuggestDiscoverableType; use crate::config_loader::RequirementSource; +use crate::plugins::PluginsManager; use assert_matches::assert_matches; use codex_config::CONFIG_TOML_FILE; +use codex_config::config_toml::AgentRoleToml; +use codex_config::config_toml::AgentsToml; +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::RealtimeToml; +use codex_config::config_toml::RealtimeWsMode; +use codex_config::config_toml::RealtimeWsVersion; +use codex_config::config_toml::ToolsToml; +use codex_config::permissions_toml::FilesystemPermissionToml; +use codex_config::permissions_toml::FilesystemPermissionsToml; +use codex_config::permissions_toml::NetworkDomainPermissionToml; +use codex_config::permissions_toml::NetworkDomainPermissionsToml; +use codex_config::permissions_toml::NetworkToml; +use codex_config::permissions_toml::PermissionProfileToml; +use codex_config::permissions_toml::PermissionsToml; +use codex_config::profile_toml::ConfigProfile; +use codex_config::types::AppToolApproval; +use codex_config::types::ApprovalsReviewer; +use codex_config::types::BundledSkillsConfig; +use codex_config::types::FeedbackConfigToml; +use codex_config::types::HistoryPersistence; +use codex_config::types::McpServerToolConfig; +use codex_config::types::McpServerTransportConfig; +use codex_config::types::MemoriesConfig; +use codex_config::types::MemoriesToml; +use codex_config::types::ModelAvailabilityNuxConfig; +use codex_config::types::NotificationMethod; +use codex_config::types::Notifications; +use codex_config::types::SandboxWorkspaceWrite; +use codex_config::types::SkillsConfig; +use codex_config::types::ToolSuggestDiscoverableType; +use codex_config::types::Tui; use codex_features::Feature; use codex_features::FeaturesToml; +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::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 serde::Deserialize; use tempfile::tempdir; @@ -58,6 +87,7 @@ fn stdio_mcp(command: &str) -> McpServerConfig { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), } } @@ -78,6 +108,7 @@ fn http_mcp(url: &str) -> McpServerConfig { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), } } @@ -242,6 +273,26 @@ web_search = false ); } +#[test] +fn rejects_provider_auth_with_env_key() { + let err = toml::from_str::( + r#" +[model_providers.corp] +name = "Corp" +env_key = "CORP_TOKEN" + +[model_providers.corp.auth] +command = "print-token" +"#, + ) + .unwrap_err(); + + assert!( + err.to_string() + .contains("model_providers.corp: provider auth cannot be combined with env_key") + ); +} + #[test] fn config_toml_deserializes_model_availability_nux() { let toml = r#" @@ -306,7 +357,9 @@ enabled = true proxy_url = "http://127.0.0.1:43128" enable_socks5 = false allow_upstream_proxy = false -allowed_domains = ["openai.com"] + +[permissions.workspace.network.domains] +"openai.com" = "allow" "#; let cfg: ConfigToml = toml::from_str(toml).expect("TOML deserialization should succeed for permissions profiles"); @@ -343,9 +396,13 @@ allowed_domains = ["openai.com"] dangerously_allow_non_loopback_proxy: None, dangerously_allow_all_unix_sockets: None, mode: None, - allowed_domains: Some(vec!["openai.com".to_string()]), - denied_domains: None, - allow_unix_sockets: None, + domains: Some(NetworkDomainPermissionsToml { + entries: BTreeMap::from([( + "openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + }), + unix_sockets: None, allow_local_binding: None, }), }, @@ -424,7 +481,12 @@ async fn permissions_profiles_network_disabled_by_default_does_not_start_proxy() )]), }), network: Some(NetworkToml { - allowed_domains: Some(vec!["openai.com".to_string()]), + domains: Some(NetworkDomainPermissionsToml { + entries: BTreeMap::from([( + "openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + }), ..Default::default() }), }, @@ -501,7 +563,7 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: }, FileSystemSandboxEntry { path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(None), + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), }, access: FileSystemAccessMode::Write, }, @@ -710,7 +772,10 @@ async fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<( config.permissions.file_system_sandbox_policy, FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Special { - value: FileSystemSpecialPath::unknown(":future_special_path", None), + value: FileSystemSpecialPath::unknown( + ":future_special_path", + /*subpath*/ None + ), }, access: FileSystemAccessMode::Read, }]), @@ -984,10 +1049,10 @@ network_access = false # This should be ignored. let resolution = sandbox_full_access_cfg .derive_sandbox_policy( sandbox_mode_override, - None, + /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, &PathBuf::from("/tmp/test"), - None, + /*sandbox_policy_constraint*/ None, ) .await; assert_eq!(resolution, SandboxPolicy::DangerFullAccess); @@ -1005,10 +1070,10 @@ network_access = true # This should be ignored. let resolution = sandbox_read_only_cfg .derive_sandbox_policy( sandbox_mode_override, - None, + /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, &PathBuf::from("/tmp/test"), - None, + /*sandbox_policy_constraint*/ None, ) .await; assert_eq!(resolution, SandboxPolicy::new_read_only_policy()); @@ -1034,10 +1099,10 @@ exclude_slash_tmp = true let resolution = sandbox_workspace_write_cfg .derive_sandbox_policy( sandbox_mode_override, - None, + /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, &PathBuf::from("/tmp/test"), - None, + /*sandbox_policy_constraint*/ None, ) .await; if cfg!(target_os = "windows") { @@ -1078,10 +1143,10 @@ trust_level = "trusted" let resolution = sandbox_workspace_write_cfg .derive_sandbox_policy( sandbox_mode_override, - None, + /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, &PathBuf::from("/tmp/test"), - None, + /*sandbox_policy_constraint*/ None, ) .await; if cfg!(target_os = "windows") { @@ -1261,7 +1326,7 @@ fn filter_mcp_servers_by_allowlist_allows_all_when_unset() { ("server-b".to_string(), http_mcp("https://example.com/b")), ]); - filter_mcp_servers_by_requirements(&mut servers, None); + filter_mcp_servers_by_requirements(&mut servers, /*mcp_requirements*/ None); assert_eq!( servers @@ -1623,7 +1688,7 @@ profile = "project" "#, )?; - let config = ConfigBuilder::default() + let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .harness_overrides(ConfigOverrides { cwd: Some(workspace.path().to_path_buf()), @@ -1782,10 +1847,7 @@ async fn responses_websocket_features_do_not_change_wire_api() -> std::io::Resul ) .await?; - assert_eq!( - config.model_provider.wire_api, - crate::model_provider_info::WireApi::Responses - ); + assert_eq!(config.model_provider.wire_api, WireApi::Responses); } Ok(()) @@ -1823,12 +1885,7 @@ async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> { std::fs::write(&config_path, "mcp_oauth_credentials_store = \"file\"\n")?; std::fs::write(&managed_path, "mcp_oauth_credentials_store = \"keyring\"\n")?; - let overrides = LoaderOverrides { - managed_config_path: Some(managed_path.clone()), - #[cfg(target_os = "macos")] - managed_preferences_base64: None, - macos_managed_config_requirements_base64: None, - }; + let overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()); let cwd = codex_home.path().abs(); let config_layer_stack = load_config_layers_state( @@ -1898,12 +1955,13 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, ); apply_blocking( codex_home.path(), - None, + /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -1933,7 +1991,7 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> { let empty = BTreeMap::new(); apply_blocking( codex_home.path(), - None, + /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(empty.clone())], )?; let loaded = load_global_mcp_servers(codex_home.path()).await?; @@ -1953,12 +2011,7 @@ async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> { )?; std::fs::write(&managed_path, "model = \"managed_config\"\n")?; - let overrides = LoaderOverrides { - managed_config_path: Some(managed_path), - #[cfg(target_os = "macos")] - managed_preferences_base64: None, - macos_managed_config_requirements_base64: None, - }; + let overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path); let cwd = codex_home.path().abs(); let config_layer_stack = load_config_layers_state( @@ -2003,6 +2056,100 @@ startup_timeout_ms = 2500 Ok(()) } +#[test] +fn mcp_servers_toml_parses_per_tool_approval_overrides() { + let config = toml::from_str::( + r#" +[mcp_servers.docs] +command = "docs-server" +name = "Docs" + +[mcp_servers.docs.tools.search] +approval_mode = "approve" +"#, + ) + .expect("TOML deserialization should succeed"); + let tool = config + .mcp_servers + .get("docs") + .and_then(|server| server.tools.get("search")) + .expect("docs/search tool config exists"); + + assert_eq!( + tool, + &McpServerToolConfig { + approval_mode: Some(AppToolApproval::Approve), + } + ); +} + +#[test] +fn mcp_servers_toml_ignores_unknown_server_fields() { + let config = toml::from_str::( + r#" +[mcp_servers.docs] +command = "docs-server" +trust_level = "trusted" +"#, + ) + .expect("unknown MCP server fields should be ignored"); + + assert_eq!( + config.mcp_servers.get("docs"), + Some(&stdio_mcp("docs-server")) + ); +} + +#[test] +fn mcp_servers_toml_parses_tool_approval_override_for_reserved_name() { + let config = toml::from_str::( + r#" +[mcp_servers.docs] +command = "docs-server" + +[mcp_servers.docs.tools.command] +approval_mode = "approve" +"#, + ) + .expect("TOML deserialization should succeed"); + let tool = config + .mcp_servers + .get("docs") + .and_then(|server| server.tools.get("command")) + .expect("docs/command tool config exists"); + + assert_eq!( + tool, + &McpServerToolConfig { + approval_mode: Some(AppToolApproval::Approve), + } + ); +} + +#[test] +fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + + let mcp_config = config.to_mcp_config(&plugins_manager); + assert!(mcp_config.apps_enabled); + + let _ = config.features.disable(Feature::Apps); + let mcp_config = config.to_mcp_config(&plugins_manager); + assert!(!mcp_config.apps_enabled); + + let _ = config.features.enable(Feature::Apps); + let mcp_config = config.to_mcp_config(&plugins_manager); + assert!(mcp_config.apps_enabled); + + Ok(()) +} + #[tokio::test] async fn load_global_mcp_servers_rejects_inline_bearer_token() -> anyhow::Result<()> { let codex_home = TempDir::new()?; @@ -2054,12 +2201,13 @@ async fn replace_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, )]); apply_blocking( codex_home.path(), - None, + /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -2126,12 +2274,13 @@ async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, )]); apply_blocking( codex_home.path(), - None, + /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -2178,12 +2327,13 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, )]); apply_blocking( codex_home.path(), - None, + /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -2228,12 +2378,13 @@ async fn replace_mcp_servers_streamable_http_serializes_bearer_token() -> anyhow disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, )]); apply_blocking( codex_home.path(), - None, + /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -2294,11 +2445,12 @@ async fn replace_mcp_servers_streamable_http_serializes_custom_headers() -> anyh disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, )]); apply_blocking( codex_home.path(), - None, + /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -2372,12 +2524,13 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, )]); apply_blocking( codex_home.path(), - None, + /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; let serialized_with_optional = std::fs::read_to_string(&config_path)?; @@ -2403,11 +2556,12 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, ); apply_blocking( codex_home.path(), - None, + /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -2469,6 +2623,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers() disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, ), ( @@ -2490,13 +2645,14 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers() disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, ), ]); apply_blocking( codex_home.path(), - None, + /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -2574,12 +2730,13 @@ async fn replace_mcp_servers_serializes_disabled_flag() -> anyhow::Result<()> { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, )]); apply_blocking( codex_home.path(), - None, + /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -2620,12 +2777,13 @@ async fn replace_mcp_servers_serializes_required_flag() -> anyhow::Result<()> { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, )]); apply_blocking( codex_home.path(), - None, + /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -2666,12 +2824,13 @@ async fn replace_mcp_servers_serializes_tool_filters() -> anyhow::Result<()> { disabled_tools: Some(vec!["blocked".to_string()]), scopes: None, oauth_resource: None, + tools: HashMap::new(), }, )]); apply_blocking( codex_home.path(), - None, + /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -2716,12 +2875,13 @@ async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyh disabled_tools: None, scopes: None, oauth_resource: Some("https://resource.example.com".to_string()), + tools: HashMap::new(), }, )]); apply_blocking( codex_home.path(), - None, + /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -2875,7 +3035,7 @@ async fn set_feature_enabled_updates_profile() -> anyhow::Result<()> { ConfigEditsBuilder::new(codex_home.path()) .with_profile(Some("dev")) - .set_feature_enabled("guardian_approval", true) + .set_feature_enabled("guardian_approval", /*enabled*/ true) .apply() .await?; @@ -2911,13 +3071,13 @@ async fn set_feature_enabled_persists_default_false_feature_disable_in_profile() ConfigEditsBuilder::new(codex_home.path()) .with_profile(Some("dev")) - .set_feature_enabled("guardian_approval", true) + .set_feature_enabled("guardian_approval", /*enabled*/ true) .apply() .await?; ConfigEditsBuilder::new(codex_home.path()) .with_profile(Some("dev")) - .set_feature_enabled("guardian_approval", false) + .set_feature_enabled("guardian_approval", /*enabled*/ false) .apply() .await?; @@ -2951,13 +3111,13 @@ async fn set_feature_enabled_profile_disable_overrides_root_enable() -> anyhow:: let codex_home = TempDir::new()?; ConfigEditsBuilder::new(codex_home.path()) - .set_feature_enabled("guardian_approval", true) + .set_feature_enabled("guardian_approval", /*enabled*/ true) .apply() .await?; ConfigEditsBuilder::new(codex_home.path()) .with_profile(Some("dev")) - .set_feature_enabled("guardian_approval", false) + .set_feature_enabled("guardian_approval", /*enabled*/ false) .apply() .await?; @@ -3191,7 +3351,7 @@ nickname_candidates = ["Hypatia", "Noether"] ) .await?; - let config = ConfigBuilder::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())) .build() @@ -3245,7 +3405,7 @@ nickname_candidates = ["Noether"] ) .await?; - let config = ConfigBuilder::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())) .build() @@ -3308,7 +3468,7 @@ model = "gpt-5" ) .await?; - let config = ConfigBuilder::default() + let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .harness_overrides(ConfigOverrides { cwd: Some(nested_cwd), @@ -3362,7 +3522,7 @@ config_file = "./agents/researcher.toml" ) .await?; - let config = ConfigBuilder::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())) .build() @@ -3415,7 +3575,7 @@ description = "Review role" ) .await?; - let config = ConfigBuilder::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())) .build() @@ -3477,7 +3637,7 @@ developer_instructions = "Review carefully" ) .await?; - let config = ConfigBuilder::default() + let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .harness_overrides(ConfigOverrides { cwd: Some(nested_cwd), @@ -3532,7 +3692,7 @@ config_file = "./agents/researcher.toml" ) .await?; - let config = ConfigBuilder::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())) .build() @@ -3584,7 +3744,7 @@ nickname_candidates = ["Atlas"] ) .await?; - let config = ConfigBuilder::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())) .build() @@ -3718,7 +3878,7 @@ developer_instructions = "Write carefully" "#, )?; - let config = ConfigBuilder::default() + let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .harness_overrides(ConfigOverrides { cwd: Some(nested_cwd), @@ -3842,7 +4002,7 @@ model = "gpt-5" ) .await?; - let config = ConfigBuilder::default() + let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .harness_overrides(ConfigOverrides { cwd: Some(nested_cwd), @@ -3962,7 +4122,7 @@ model = "gpt-5-mini" ) .await?; - let config = ConfigBuilder::default() + let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .harness_overrides(ConfigOverrides { cwd: Some(nested_cwd), @@ -4150,8 +4310,8 @@ async fn load_config_rejects_unsafe_agent_role_nickname_candidates() -> std::io: async fn model_catalog_json_loads_from_path() -> std::io::Result<()> { let codex_home = TempDir::new()?; let catalog_path = codex_home.path().join("catalog.json"); - let mut catalog: ModelsResponse = - serde_json::from_str(include_str!("../../models.json")).expect("valid models.json"); + let mut catalog = bundled_models_response() + .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); catalog.models = catalog.models.into_iter().take(1).collect(); std::fs::write( &catalog_path, @@ -4267,9 +4427,10 @@ model_verbosity = "high" name: "OpenAI custom".to_string(), base_url: Some("https://api.openai.com/v1".to_string()), env_key: Some("OPENAI_API_KEY".to_string()), - wire_api: crate::WireApi::Responses, + wire_api: WireApi::Responses, env_key_instructions: None, experimental_bearer_token: None, + auth: None, query_params: None, http_headers: None, env_http_headers: None, @@ -4281,7 +4442,8 @@ model_verbosity = "high" supports_websockets: false, }; let model_provider_map = { - let mut model_provider_map = built_in_model_providers(/* openai_base_url */ None); + let mut model_provider_map = + built_in_model_providers(/* openai_base_url */ /*openai_base_url*/ None); model_provider_map.insert("openai-custom".to_string(), openai_custom_provider.clone()); model_provider_map }; @@ -4349,10 +4511,9 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, windows_sandbox_private_desktop: true, - macos_seatbelt_profile_extensions: None, }, approvals_reviewer: ApprovalsReviewer::User, - enforce_residency: Constrained::allow_any(None), + enforce_residency: Constrained::allow_any(/*initial_value*/ None), user_instructions: None, notify: None, cwd: fixture.cwd(), @@ -4378,6 +4539,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { history: History::default(), ephemeral: false, file_opener: UriBasedFileOpener::VsCode, + codex_self_exe: None, codex_linux_sandbox_exe: None, main_execve_wrapper_exe: None, js_repl_node_path: None, @@ -4393,7 +4555,6 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { model_verbosity: None, personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), - experimental_exec_server_url: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, @@ -4403,7 +4564,11 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { experimental_realtime_ws_startup_context: None, base_instructions: None, developer_instructions: None, + developer_instructions_override: None, guardian_developer_instructions: None, + include_permissions_instructions: true, + include_apps_instructions: true, + include_environment_context: true, compact_prompt: None, commit_attribution: None, forced_chatgpt_workspace_id: None, @@ -4494,10 +4659,9 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, windows_sandbox_private_desktop: true, - macos_seatbelt_profile_extensions: None, }, approvals_reviewer: ApprovalsReviewer::User, - enforce_residency: Constrained::allow_any(None), + enforce_residency: Constrained::allow_any(/*initial_value*/ None), user_instructions: None, notify: None, cwd: fixture.cwd(), @@ -4523,6 +4687,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { history: History::default(), ephemeral: false, file_opener: UriBasedFileOpener::VsCode, + codex_self_exe: None, codex_linux_sandbox_exe: None, main_execve_wrapper_exe: None, js_repl_node_path: None, @@ -4538,7 +4703,6 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { model_verbosity: None, personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), - experimental_exec_server_url: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, @@ -4548,7 +4712,11 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { experimental_realtime_ws_startup_context: None, base_instructions: None, developer_instructions: None, + developer_instructions_override: None, guardian_developer_instructions: None, + include_permissions_instructions: true, + include_apps_instructions: true, + include_environment_context: true, compact_prompt: None, commit_attribution: None, forced_chatgpt_workspace_id: None, @@ -4637,10 +4805,9 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, windows_sandbox_private_desktop: true, - macos_seatbelt_profile_extensions: None, }, approvals_reviewer: ApprovalsReviewer::User, - enforce_residency: Constrained::allow_any(None), + enforce_residency: Constrained::allow_any(/*initial_value*/ None), user_instructions: None, notify: None, cwd: fixture.cwd(), @@ -4666,6 +4833,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { history: History::default(), ephemeral: false, file_opener: UriBasedFileOpener::VsCode, + codex_self_exe: None, codex_linux_sandbox_exe: None, main_execve_wrapper_exe: None, js_repl_node_path: None, @@ -4681,7 +4849,6 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { model_verbosity: None, personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), - experimental_exec_server_url: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, @@ -4691,7 +4858,11 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { experimental_realtime_ws_startup_context: None, base_instructions: None, developer_instructions: None, + developer_instructions_override: None, guardian_developer_instructions: None, + include_permissions_instructions: true, + include_apps_instructions: true, + include_environment_context: true, compact_prompt: None, commit_attribution: None, forced_chatgpt_workspace_id: None, @@ -4765,10 +4936,9 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { shell_environment_policy: ShellEnvironmentPolicy::default(), windows_sandbox_mode: None, windows_sandbox_private_desktop: true, - macos_seatbelt_profile_extensions: None, }, approvals_reviewer: ApprovalsReviewer::User, - enforce_residency: Constrained::allow_any(None), + enforce_residency: Constrained::allow_any(/*initial_value*/ None), user_instructions: None, notify: None, cwd: fixture.cwd(), @@ -4794,6 +4964,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { history: History::default(), ephemeral: false, file_opener: UriBasedFileOpener::VsCode, + codex_self_exe: None, codex_linux_sandbox_exe: None, main_execve_wrapper_exe: None, js_repl_node_path: None, @@ -4809,7 +4980,6 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { model_verbosity: Some(Verbosity::High), personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), - experimental_exec_server_url: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, @@ -4819,7 +4989,11 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { experimental_realtime_ws_startup_context: None, base_instructions: None, developer_instructions: None, + developer_instructions_override: None, guardian_developer_instructions: None, + include_permissions_instructions: true, + include_apps_instructions: true, + include_environment_context: true, compact_prompt: None, commit_attribution: None, forced_chatgpt_workspace_id: None, @@ -4865,6 +5039,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() let requirements_toml = crate::config_loader::ConfigRequirementsToml { allowed_approval_policies: None, + allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: Some(vec![ crate::config_loader::WebSearchModeRequirement::Cached, @@ -5114,11 +5289,11 @@ trust_level = "untrusted" let resolution = cfg .derive_sandbox_policy( - None, - None, + /*sandbox_mode_override*/ None, + /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, &PathBuf::from("/tmp/test"), - None, + /*sandbox_policy_constraint*/ None, ) .await; @@ -5168,8 +5343,8 @@ async fn derive_sandbox_policy_falls_back_to_constraint_value_for_implicit_defau let resolution = cfg .derive_sandbox_policy( - None, - None, + /*sandbox_mode_override*/ None, + /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, &project_path, Some(&constrained), @@ -5210,8 +5385,8 @@ async fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallb let resolution = cfg .derive_sandbox_policy( - None, - None, + /*sandbox_mode_override*/ None, + /*profile_sandbox_mode*/ None, WindowsSandboxLevel::Disabled, &project_path, Some(&constrained), @@ -5229,7 +5404,11 @@ async fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallb #[test] fn test_resolve_oss_provider_explicit_override() { let config_toml = ConfigToml::default(); - let result = resolve_oss_provider(Some("custom-provider"), &config_toml, None); + let result = resolve_oss_provider( + Some("custom-provider"), + &config_toml, + /*config_profile*/ None, + ); assert_eq!(result, Some("custom-provider".to_string())); } @@ -5246,7 +5425,11 @@ fn test_resolve_oss_provider_from_profile() { ..Default::default() }; - let result = resolve_oss_provider(None, &config_toml, Some("test-profile".to_string())); + let result = resolve_oss_provider( + /*explicit_provider*/ None, + &config_toml, + Some("test-profile".to_string()), + ); assert_eq!(result, Some("profile-provider".to_string())); } @@ -5257,7 +5440,11 @@ fn test_resolve_oss_provider_from_global_config() { ..Default::default() }; - let result = resolve_oss_provider(None, &config_toml, None); + let result = resolve_oss_provider( + /*explicit_provider*/ None, + &config_toml, + /*config_profile*/ None, + ); assert_eq!(result, Some("global-provider".to_string())); } @@ -5272,14 +5459,22 @@ fn test_resolve_oss_provider_profile_fallback_to_global() { ..Default::default() }; - let result = resolve_oss_provider(None, &config_toml, Some("test-profile".to_string())); + let result = resolve_oss_provider( + /*explicit_provider*/ None, + &config_toml, + Some("test-profile".to_string()), + ); assert_eq!(result, Some("global-provider".to_string())); } #[test] fn test_resolve_oss_provider_none_when_not_configured() { let config_toml = ConfigToml::default(); - let result = resolve_oss_provider(None, &config_toml, None); + let result = resolve_oss_provider( + /*explicit_provider*/ None, + &config_toml, + /*config_profile*/ None, + ); assert_eq!(result, None); } @@ -5449,7 +5644,7 @@ async fn requirements_disallowing_default_sandbox_falls_back_to_required_default -> std::io::Result<()> { let codex_home = TempDir::new()?; - let config = ConfigBuilder::default() + let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .cloud_requirements(CloudRequirementsLoader::new(async { Ok(Some(crate::config_loader::ConfigRequirementsToml { @@ -5479,6 +5674,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s let requirements = crate::config_loader::ConfigRequirementsToml { allowed_approval_policies: None, + allowed_approvals_reviewers: None, allowed_sandbox_modes: Some(vec![crate::config_loader::SandboxModeRequirement::ReadOnly]), allowed_web_search_modes: None, feature_requirements: None, @@ -5490,7 +5686,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s guardian_developer_instructions: None, }; - let config = ConfigBuilder::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())) .cloud_requirements(CloudRequirementsLoader::new(async move { @@ -5515,7 +5711,7 @@ async fn requirements_web_search_mode_overrides_danger_full_access_default() -> "#, )?; - let config = ConfigBuilder::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())) .cloud_requirements(CloudRequirementsLoader::new(async { @@ -5556,7 +5752,7 @@ trust_level = "untrusted" ), )?; - let config = ConfigBuilder::default() + let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(workspace.path().to_path_buf())) .cloud_requirements(CloudRequirementsLoader::new(async { @@ -5585,7 +5781,7 @@ async fn explicit_approval_policy_falls_back_when_disallowed_by_requirements() - "#, )?; - let config = ConfigBuilder::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())) .cloud_requirements(CloudRequirementsLoader::new(async { @@ -5607,7 +5803,7 @@ async fn explicit_approval_policy_falls_back_when_disallowed_by_requirements() - async fn feature_requirements_normalize_effective_feature_values() -> std::io::Result<()> { let codex_home = TempDir::new()?; - let config = ConfigBuilder::default() + let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(codex_home.path().to_path_buf()) .cloud_requirements(CloudRequirementsLoader::new(async { Ok(Some(crate::config_loader::ConfigRequirementsToml { @@ -5649,7 +5845,7 @@ shell_tool = true "#, )?; - let config = ConfigBuilder::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())) .cloud_requirements(CloudRequirementsLoader::new(async { @@ -5680,90 +5876,47 @@ shell_tool = true Ok(()) } -#[cfg(target_os = "linux")] -#[test] -fn system_bwrap_warning_reports_missing_system_bwrap() { - let warning = system_bwrap_warning_for_path(Path::new("/definitely/not/a/bwrap")) - .expect("missing system bwrap should emit a warning"); - - assert!(warning.contains("could not find system bubblewrap")); -} - -#[cfg(target_os = "linux")] -#[test] -fn system_bwrap_warning_reports_too_old_system_bwrap() { - let fake_bwrap = write_fake_bwrap( - r#"#!/bin/sh -if [ "$1" = "--help" ]; then - echo 'usage: bwrap [OPTION...] COMMAND' - exit 0 -fi -exit 1 -"#, - ); - let fake_bwrap_path: &Path = fake_bwrap.as_ref(); - let warning = system_bwrap_warning_for_path(fake_bwrap_path) - .expect("old system bwrap should emit a warning"); - - assert!(warning.contains("too old to support `--argv0`")); -} - -#[cfg(target_os = "linux")] -#[test] -fn system_bwrap_warning_skips_supported_system_bwrap() { - let fake_bwrap = write_fake_bwrap( - r#"#!/bin/sh -if [ "$1" = "--help" ]; then - echo ' --argv0 PROGRAM' - exit 0 -fi -exit 1 -"#, - ); - let fake_bwrap_path: &Path = fake_bwrap.as_ref(); - - assert_eq!(system_bwrap_warning_for_path(fake_bwrap_path), None); -} - -#[cfg(not(target_os = "linux"))] -#[test] -fn system_bwrap_warning_is_disabled_off_linux() { - assert!(system_bwrap_warning().is_none()); -} - -#[cfg(target_os = "linux")] -fn write_fake_bwrap(contents: &str) -> tempfile::TempPath { - use std::fs; - use std::os::unix::fs::PermissionsExt; - use tempfile::NamedTempFile; - - // Bazel can mount the OS temp directory `noexec`, so prefer the current - // working directory for fake executables and fall back to the default temp - // dir outside that environment. - let temp_file = std::env::current_dir() - .ok() - .and_then(|dir| NamedTempFile::new_in(dir).ok()) - .unwrap_or_else(|| NamedTempFile::new().expect("temp file")); - // Linux rejects exec-ing a file that is still open for writing. - let path = temp_file.into_temp_path(); - fs::write(&path, contents).expect("write fake bwrap"); - let permissions = fs::Permissions::from_mode(0o755); - fs::set_permissions(&path, permissions).expect("chmod fake bwrap"); - path -} - #[tokio::test] async fn approvals_reviewer_defaults_to_manual_only_without_guardian_feature() -> std::io::Result<()> { let codex_home = TempDir::new()?; + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await?; + + assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User); + Ok(()) +} + +#[tokio::test] +async fn prompt_instruction_blocks_can_be_disabled_from_config_and_profiles() -> std::io::Result<()> +{ + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"include_permissions_instructions = false +include_apps_instructions = false +include_environment_context = false +profile = "chatty" + +[profiles.chatty] +include_permissions_instructions = true +include_environment_context = true +"#, + )?; + let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await?; - assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User); + assert!(config.include_permissions_instructions); + assert!(!config.include_apps_instructions); + assert!(config.include_environment_context); Ok(()) } @@ -5778,7 +5931,7 @@ guardian_approval = true "#, )?; - let config = ConfigBuilder::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())) .build() @@ -5798,7 +5951,7 @@ async fn approvals_reviewer_can_be_set_in_config_without_guardian_approval() -> "#, )?; - let config = ConfigBuilder::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())) .build() @@ -5821,7 +5974,7 @@ approvals_reviewer = "guardian_subagent" "#, )?; - let config = ConfigBuilder::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())) .build() @@ -5834,6 +5987,138 @@ approvals_reviewer = "guardian_subagent" Ok(()) } +#[tokio::test] +async fn requirements_disallowing_default_approvals_reviewer_falls_back_to_required_default() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(crate::config_loader::ConfigRequirementsToml { + allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::GuardianSubagent]), + ..Default::default() + })) + })) + .build() + .await?; + + assert_eq!( + config.approvals_reviewer, + ApprovalsReviewer::GuardianSubagent + ); + Ok(()) +} + +#[tokio::test] +async fn root_approvals_reviewer_falls_back_when_disallowed_by_requirements() -> std::io::Result<()> +{ + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"approvals_reviewer = "user" +"#, + )?; + + 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())) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(crate::config_loader::ConfigRequirementsToml { + allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::GuardianSubagent]), + ..Default::default() + })) + })) + .build() + .await?; + + assert_eq!( + config.approvals_reviewer, + ApprovalsReviewer::GuardianSubagent + ); + assert!( + config.startup_warnings.iter().any(|warning| { + warning + .contains("Configured value for `approvals_reviewer` is disallowed by requirements") + }), + "{:?}", + config.startup_warnings + ); + Ok(()) +} + +#[tokio::test] +async fn profile_approvals_reviewer_falls_back_when_disallowed_by_requirements() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"profile = "default" + +[profiles.default] +approvals_reviewer = "user" +"#, + )?; + + 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())) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(crate::config_loader::ConfigRequirementsToml { + allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::GuardianSubagent]), + ..Default::default() + })) + })) + .build() + .await?; + + assert_eq!( + config.approvals_reviewer, + ApprovalsReviewer::GuardianSubagent + ); + Ok(()) +} + +#[tokio::test] +async fn approvals_reviewer_preserves_valid_user_choice_when_allowed_by_requirements() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"approvals_reviewer = "guardian_subagent" +"#, + )?; + + 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())) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(crate::config_loader::ConfigRequirementsToml { + allowed_approvals_reviewers: Some(vec![ + ApprovalsReviewer::User, + ApprovalsReviewer::GuardianSubagent, + ]), + ..Default::default() + })) + })) + .build() + .await?; + + assert_eq!( + config.approvals_reviewer, + ApprovalsReviewer::GuardianSubagent + ); + assert!( + config + .startup_warnings + .iter() + .all(|warning| !warning.contains("approvals_reviewer")), + "{:?}", + config.startup_warnings + ); + Ok(()) +} + #[tokio::test] async fn smart_approvals_alias_is_ignored() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -5844,7 +6129,7 @@ smart_approvals = true "#, )?; - let config = ConfigBuilder::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())) .build() @@ -5873,7 +6158,7 @@ smart_approvals = true "#, )?; - let config = ConfigBuilder::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())) .build() @@ -6042,35 +6327,6 @@ experimental_realtime_start_instructions = "start instructions from config" Ok(()) } -#[tokio::test] -async fn experimental_exec_server_url_loads_from_config_toml() -> std::io::Result<()> { - let cfg: ConfigToml = toml::from_str( - r#" -experimental_exec_server_url = "http://127.0.0.1:8080" -"#, - ) - .expect("TOML deserialization should succeed"); - - assert_eq!( - cfg.experimental_exec_server_url.as_deref(), - Some("http://127.0.0.1:8080") - ); - - let codex_home = TempDir::new()?; - let config = Config::load_from_base_config_with_overrides( - cfg, - ConfigOverrides::default(), - codex_home.path().to_path_buf(), - ) - .await?; - - assert_eq!( - config.experimental_exec_server_url.as_deref(), - Some("http://127.0.0.1:8080") - ); - Ok(()) -} - #[tokio::test] async fn experimental_realtime_ws_base_url_loads_from_config_toml() -> std::io::Result<()> { let cfg: ConfigToml = toml::from_str( diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 3f7e3b1170..1b3d700ab7 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -1,9 +1,8 @@ -use crate::config::types::McpServerConfig; -use crate::config::types::Notice; use crate::path_utils::resolve_symlink_write_paths; use crate::path_utils::write_atomically; use anyhow::Context; use codex_config::CONFIG_TOML_FILE; +use codex_config::types::McpServerConfig; use codex_features::FEATURES; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ServiceTier; @@ -20,6 +19,8 @@ use toml_edit::Item as TomlItem; use toml_edit::Table as TomlTable; use toml_edit::value; +const NOTICE_TABLE_KEY: &str = "notice"; + /// Discrete config mutations supported by the persistence engine. #[derive(Clone, Debug)] pub enum ConfigEdit { @@ -125,8 +126,10 @@ pub fn model_availability_nux_count_edits(shown_count: &HashMap) -> // TODO(jif) move to a dedicated file mod document_helpers { - use crate::config::types::McpServerConfig; - use crate::config::types::McpServerTransportConfig; + use codex_config::types::AppToolApproval; + use codex_config::types::McpServerConfig; + use codex_config::types::McpServerToolConfig; + use codex_config::types::McpServerTransportConfig; use toml_edit::Array as TomlArray; use toml_edit::InlineTable; use toml_edit::Item as TomlItem; @@ -248,10 +251,32 @@ mod document_helpers { { entry["oauth_resource"] = value(resource.clone()); } + if !config.tools.is_empty() { + let mut tools = new_implicit_table(); + let mut tool_entries: Vec<_> = config.tools.iter().collect(); + tool_entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + for (name, tool_config) in tool_entries { + tools.insert(name, serialize_mcp_server_tool(tool_config)); + } + entry.insert("tools", TomlItem::Table(tools)); + } entry } + fn serialize_mcp_server_tool(config: &McpServerToolConfig) -> TomlItem { + let mut entry = TomlTable::new(); + entry.set_implicit(false); + if let Some(approval_mode) = config.approval_mode { + entry["approval_mode"] = value(match approval_mode { + AppToolApproval::Auto => "auto", + AppToolApproval::Prompt => "prompt", + AppToolApproval::Approve => "approve", + }); + } + TomlItem::Table(entry) + } + pub(super) fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem { TomlItem::Table(serialize_mcp_server_table(config)) } @@ -363,29 +388,29 @@ impl ConfigDocument { )), ConfigEdit::SetNoticeHideFullAccessWarning(acknowledged) => Ok(self.write_value( Scope::Global, - &[Notice::TABLE_KEY, "hide_full_access_warning"], + &[NOTICE_TABLE_KEY, "hide_full_access_warning"], value(*acknowledged), )), ConfigEdit::SetNoticeHideWorldWritableWarning(acknowledged) => Ok(self.write_value( Scope::Global, - &[Notice::TABLE_KEY, "hide_world_writable_warning"], + &[NOTICE_TABLE_KEY, "hide_world_writable_warning"], value(*acknowledged), )), ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged) => Ok(self.write_value( Scope::Global, - &[Notice::TABLE_KEY, "hide_rate_limit_model_nudge"], + &[NOTICE_TABLE_KEY, "hide_rate_limit_model_nudge"], value(*acknowledged), )), ConfigEdit::SetNoticeHideModelMigrationPrompt(migration_config, acknowledged) => { Ok(self.write_value( Scope::Global, - &[Notice::TABLE_KEY, migration_config.as_str()], + &[NOTICE_TABLE_KEY, migration_config.as_str()], value(*acknowledged), )) } ConfigEdit::RecordModelMigrationSeen { from, to } => Ok(self.write_value( Scope::Global, - &[Notice::TABLE_KEY, "model_migrations", from.as_str()], + &[NOTICE_TABLE_KEY, "model_migrations", from.as_str()], value(to.clone()), )), ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged) => Ok(self.write_value( diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 632716f00d..314d8badb3 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -1,5 +1,7 @@ use super::*; -use crate::config::types::McpServerTransportConfig; +use codex_config::types::AppToolApproval; +use codex_config::types::McpServerToolConfig; +use codex_config::types::McpServerTransportConfig; use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; #[cfg(unix)] @@ -14,7 +16,7 @@ fn blocking_set_model_top_level() { apply_blocking( codex_home, - None, + /*profile*/ None, &[ConfigEdit::SetModel { model: Some("gpt-5.1-codex".to_string()), effort: Some(ReasoningEffort::High), @@ -148,7 +150,7 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } apply_blocking( codex_home, - None, + /*profile*/ None, &[ConfigEdit::SetModel { model: Some("o4-mini".to_string()), effort: None, @@ -193,7 +195,7 @@ fn blocking_set_model_writes_through_symlink_chain() { apply_blocking( codex_home, - None, + /*profile*/ None, &[ConfigEdit::SetModel { model: Some("gpt-5.1-codex".to_string()), effort: Some(ReasoningEffort::High), @@ -226,7 +228,7 @@ fn blocking_set_model_replaces_symlink_on_cycle() { apply_blocking( codex_home, - None, + /*profile*/ None, &[ConfigEdit::SetModel { model: Some("gpt-5.1-codex".to_string()), effort: None, @@ -265,7 +267,7 @@ network_access = false apply_blocking( codex_home, - None, + /*profile*/ None, &[ ConfigEdit::SetPath { segments: vec![ @@ -320,7 +322,7 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } apply_blocking( codex_home, - None, + /*profile*/ None, &[ConfigEdit::SetModel { model: None, effort: Some(ReasoningEffort::High), @@ -354,7 +356,7 @@ model_reasoning_effort = "low" apply_blocking( codex_home, - None, + /*profile*/ None, &[ConfigEdit::SetModel { model: Some("o5-preview".to_string()), effort: Some(ReasoningEffort::Minimal), @@ -418,7 +420,7 @@ existing = "value" apply_blocking( codex_home, - None, + /*profile*/ None, &[ConfigEdit::SetNoticeHideFullAccessWarning(true)], ) .expect("persist"); @@ -448,7 +450,7 @@ existing = "value" apply_blocking( codex_home, - None, + /*profile*/ None, &[ConfigEdit::SetNoticeHideRateLimitModelNudge(true)], ) .expect("persist"); @@ -474,7 +476,7 @@ existing = "value" .expect("seed"); apply_blocking( codex_home, - None, + /*profile*/ None, &[ConfigEdit::SetNoticeHideModelMigrationPrompt( "hide_gpt5_1_migration_prompt".to_string(), true, @@ -503,7 +505,7 @@ existing = "value" .expect("seed"); apply_blocking( codex_home, - None, + /*profile*/ None, &[ConfigEdit::SetNoticeHideModelMigrationPrompt( "hide_gpt-5.1-codex-max_migration_prompt".to_string(), true, @@ -532,7 +534,7 @@ existing = "value" .expect("seed"); apply_blocking( codex_home, - None, + /*profile*/ None, &[ConfigEdit::RecordModelMigrationSeen { from: "gpt-5".to_string(), to: "gpt-5.1".to_string(), @@ -582,6 +584,7 @@ fn blocking_replace_mcp_servers_round_trips() { disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, ); @@ -607,12 +610,13 @@ fn blocking_replace_mcp_servers_round_trips() { disabled_tools: Some(vec!["forbidden".to_string()]), scopes: None, oauth_resource: Some("https://resource.example.com".to_string()), + tools: HashMap::new(), }, ); apply_blocking( codex_home, - None, + /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], ) .expect("persist"); @@ -643,6 +647,58 @@ B = \"2\" assert_eq!(raw, expected); } +#[test] +fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + let mut servers = BTreeMap::new(); + servers.insert( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "docs-server".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + tools: HashMap::from([( + "search".to_string(), + McpServerToolConfig { + approval_mode: Some(AppToolApproval::Approve), + }, + )]), + }, + ); + + apply_blocking( + codex_home, + /*profile*/ None, + &[ConfigEdit::ReplaceMcpServers(servers)], + ) + .expect("persist"); + + let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = "\ +[mcp_servers.docs] +command = \"docs-server\" + +[mcp_servers.docs.tools.search] +approval_mode = \"approve\" +"; + assert_eq!(raw, expected); +} + #[test] fn blocking_replace_mcp_servers_preserves_inline_comments() { let tmp = tempdir().expect("tmpdir"); @@ -676,10 +732,16 @@ foo = { command = "cmd" } disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, ); - apply_blocking(codex_home, None, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); + apply_blocking( + codex_home, + /*profile*/ None, + &[ConfigEdit::ReplaceMcpServers(servers)], + ) + .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[mcp_servers] @@ -721,10 +783,16 @@ foo = { command = "cmd" } # keep me disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, ); - apply_blocking(codex_home, None, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); + apply_blocking( + codex_home, + /*profile*/ None, + &[ConfigEdit::ReplaceMcpServers(servers)], + ) + .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[mcp_servers] @@ -765,10 +833,16 @@ foo = { command = "cmd", args = ["--flag"] } # keep me disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, ); - apply_blocking(codex_home, None, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); + apply_blocking( + codex_home, + /*profile*/ None, + &[ConfigEdit::ReplaceMcpServers(servers)], + ) + .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[mcp_servers] @@ -810,10 +884,16 @@ foo = { command = "cmd" } disabled_tools: None, scopes: None, oauth_resource: None, + tools: HashMap::new(), }, ); - apply_blocking(codex_home, None, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); + apply_blocking( + codex_home, + /*profile*/ None, + &[ConfigEdit::ReplaceMcpServers(servers)], + ) + .expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[mcp_servers] @@ -830,7 +910,7 @@ fn blocking_clear_path_noop_when_missing() { apply_blocking( codex_home, - None, + /*profile*/ None, &[ConfigEdit::ClearPath { segments: vec!["missing".to_string()], }], @@ -851,7 +931,7 @@ fn blocking_set_path_updates_notifications() { let item = value(false); apply_blocking( codex_home, - None, + /*profile*/ None, &[ConfigEdit::SetPath { segments: vec!["tui".to_string(), "notifications".to_string()], value: item, @@ -927,7 +1007,7 @@ async fn blocking_set_asynchronous_helpers_available() { let codex_home = tmp.path().to_path_buf(); ConfigEditsBuilder::new(&codex_home) - .set_hide_full_access_warning(true) + .set_hide_full_access_warning(/*acknowledged*/ true) .apply() .await .expect("persist"); @@ -969,7 +1049,7 @@ fn blocking_builder_set_realtime_audio_persists_and_clears() { ); ConfigEditsBuilder::new(codex_home) - .set_realtime_microphone(None) + .set_realtime_microphone(/*microphone*/ None) .apply_blocking() .expect("clear realtime microphone"); @@ -998,7 +1078,7 @@ fn replace_mcp_servers_blocking_clears_table_when_empty() { apply_blocking( codex_home, - None, + /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(BTreeMap::new())], ) .expect("persist"); diff --git a/codex-rs/core/src/config/managed_features.rs b/codex-rs/core/src/config/managed_features.rs index 646a161533..cbad012c6c 100644 --- a/codex-rs/core/src/config/managed_features.rs +++ b/codex-rs/core/src/config/managed_features.rs @@ -8,8 +8,8 @@ use codex_config::FeatureRequirementsToml; use codex_config::RequirementSource; use codex_config::Sourced; -use crate::config::ConfigToml; -use crate::config::profile::ConfigProfile; +use codex_config::config_toml::ConfigToml; +use codex_config::profile_toml::ConfigProfile; use codex_features::Feature; use codex_features::FeatureConfigSource; use codex_features::FeatureOverrides; @@ -96,7 +96,10 @@ impl ManagedFeatures { impl From for ManagedFeatures { fn from(features: Features) -> Self { Self { - value: ConstrainedWithSource::new(Constrained::allow_any(features), None), + value: ConstrainedWithSource::new( + Constrained::allow_any(features), + /*source*/ None, + ), pinned_features: BTreeMap::new(), } } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index d60074f787..1f2dedc6e5 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1,32 +1,5 @@ -use crate::auth::AuthCredentialsStoreMode; use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; -use crate::config::types::AppsConfigToml; -use crate::config::types::DEFAULT_OTEL_ENVIRONMENT; -use crate::config::types::History; -use crate::config::types::McpServerConfig; -use crate::config::types::McpServerDisabledReason; -use crate::config::types::McpServerTransportConfig; -use crate::config::types::MemoriesConfig; -use crate::config::types::MemoriesToml; -use crate::config::types::ModelAvailabilityNuxConfig; -use crate::config::types::Notice; -use crate::config::types::NotificationMethod; -use crate::config::types::Notifications; -use crate::config::types::OtelConfig; -use crate::config::types::OtelConfigToml; -use crate::config::types::OtelExporterKind; -use crate::config::types::PluginConfig; -use crate::config::types::SandboxWorkspaceWrite; -use crate::config::types::ShellEnvironmentPolicy; -use crate::config::types::ShellEnvironmentPolicyToml; -use crate::config::types::SkillsConfig; -use crate::config::types::ToolSuggestConfig; -use crate::config::types::ToolSuggestDiscoverable; -use crate::config::types::Tui; -use crate::config::types::UriBasedFileOpener; -use crate::config::types::WindowsSandboxModeToml; -use crate::config::types::WindowsToml; use crate::config_loader::CloudRequirementsLoader; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigLayerStackOrdering; @@ -39,33 +12,54 @@ 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::model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID; -use crate::model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; -use crate::model_provider_info::ModelProviderInfo; -use crate::model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR; -use crate::model_provider_info::OLLAMA_OSS_PROVIDER_ID; -use crate::model_provider_info::OPENAI_PROVIDER_ID; -use crate::model_provider_info::built_in_model_providers; use crate::path_utils::normalize_for_native_workdir; use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME; -use crate::protocol::AskForApproval; -use crate::protocol::ReadOnlyAccess; -use crate::protocol::SandboxPolicy; 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_app_server_protocol::Tools; -use codex_app_server_protocol::UserSavedConfig; +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::validate_model_providers; +use codex_config::profile_toml::ConfigProfile; +use codex_config::types::ApprovalsReviewer; +use codex_config::types::AuthCredentialsStoreMode; +use codex_config::types::DEFAULT_OTEL_ENVIRONMENT; +use codex_config::types::History; +use codex_config::types::McpServerConfig; +use codex_config::types::McpServerDisabledReason; +use codex_config::types::McpServerTransportConfig; +use codex_config::types::MemoriesConfig; +use codex_config::types::ModelAvailabilityNuxConfig; +use codex_config::types::Notice; +use codex_config::types::NotificationMethod; +use codex_config::types::Notifications; +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::UriBasedFileOpener; +use codex_config::types::WindowsSandboxModeToml; use codex_features::Feature; use codex_features::FeatureConfigSource; use codex_features::FeatureOverrides; use codex_features::Features; -use codex_features::FeaturesToml; -use codex_git_utils::resolve_root_git_project_for_trust; +use codex_login::AuthManagerConfig; +use codex_mcp::McpConfig; +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; +use codex_model_provider_info::built_in_model_providers; +use codex_models_manager::ModelsManagerConfig; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; @@ -76,33 +70,25 @@ use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::Verbosity; use codex_protocol::config_types::WebSearchConfig; use codex_protocol::config_types::WebSearchMode; -use codex_protocol::config_types::WebSearchToolConfig; use codex_protocol::config_types::WindowsSandboxLevel; -use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_rmcp_client::OAuthCredentialsStoreMode; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; -use schemars::JsonSchema; use serde::Deserialize; -use serde::Deserializer; -use serde::Serialize; -use similar::DiffableStr; use std::collections::BTreeMap; use std::collections::HashMap; use std::io::ErrorKind; use std::path::Path; use std::path::PathBuf; -#[cfg(target_os = "linux")] -use std::process::Command; use crate::config::permissions::compile_permission_profile; use crate::config::permissions::get_readable_roots_required_for_codex_runtime; use crate::config::permissions::network_proxy_config_from_profile_network; -use crate::config::profile::ConfigProfile; use codex_network_proxy::NetworkProxyConfig; use toml::Value as TomlValue; use toml_edit::DocumentMut; @@ -112,27 +98,20 @@ pub mod edit; mod managed_features; mod network_proxy_spec; mod permissions; -pub mod profile; -pub mod schema; -pub mod service; -pub mod types; +#[cfg(test)] +mod schema; +pub(crate) mod service; pub use codex_config::Constrained; pub use codex_config::ConstraintError; pub use codex_config::ConstraintResult; pub use codex_network_proxy::NetworkProxyAuditMetadata; - +pub use codex_sandboxing::system_bwrap_warning; pub use managed_features::ManagedFeatures; pub use network_proxy_spec::NetworkProxySpec; pub use network_proxy_spec::StartedNetworkProxy; -pub use permissions::FilesystemPermissionToml; -pub use permissions::FilesystemPermissionsToml; -pub use permissions::NetworkToml; -pub use permissions::PermissionProfileToml; -pub use permissions::PermissionsToml; pub(crate) use permissions::resolve_permission_profile; pub use service::ConfigService; pub use service::ConfigServiceError; -pub use types::ApprovalsReviewer; pub use codex_git_utils::GhostSnapshotConfig; @@ -145,55 +124,6 @@ pub(crate) const DEFAULT_AGENT_MAX_DEPTH: i32 = 1; pub(crate) const DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS: Option = None; pub const CONFIG_TOML_FILE: &str = "config.toml"; -const OPENAI_BASE_URL_ENV_VAR: &str = "OPENAI_BASE_URL"; -#[cfg(target_os = "linux")] -const SYSTEM_BWRAP_PATH: &str = "/usr/bin/bwrap"; -const RESERVED_MODEL_PROVIDER_IDS: [&str; 3] = [ - OPENAI_PROVIDER_ID, - OLLAMA_OSS_PROVIDER_ID, - LMSTUDIO_OSS_PROVIDER_ID, -]; - -#[cfg(target_os = "linux")] -pub fn system_bwrap_warning() -> Option { - system_bwrap_warning_for_path(Path::new(SYSTEM_BWRAP_PATH)) -} - -#[cfg(not(target_os = "linux"))] -pub fn system_bwrap_warning() -> Option { - None -} - -#[cfg(target_os = "linux")] -fn system_bwrap_warning_for_path(system_bwrap_path: &Path) -> Option { - if !system_bwrap_path.is_file() { - return Some(format!( - "Codex could not find system bubblewrap at {}. Please install bubblewrap with your package manager. Codex will use the vendored bubblewrap in the meantime.", - system_bwrap_path.display() - )); - } - if system_bwrap_supports_argv0(system_bwrap_path) { - return None; - } - - Some(format!( - "Codex found system bubblewrap at {}, but it is too old to support `--argv0`. Please upgrade bubblewrap with your package manager. Codex will use the vendored bubblewrap in the meantime.", - system_bwrap_path.display() - )) -} - -#[cfg(target_os = "linux")] -fn system_bwrap_supports_argv0(system_bwrap_path: &Path) -> bool { - // bubblewrap added `--argv0` in v0.9.0: - // https://github.com/containers/bubblewrap/releases/tag/v0.9.0 - let output = match Command::new(system_bwrap_path).arg("--help").output() { - Ok(output) => output, - Err(_) => return false, - }; - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - stdout.contains("--argv0") || stderr.contains("--argv0") -} fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option { let raw = std::env::var(codex_state::SQLITE_HOME_ENV).ok()?; @@ -208,6 +138,7 @@ fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option { Some(resolved_cwd.join(path)) } } + #[cfg(test)] pub(crate) async fn test_config() -> Config { let codex_home = tempfile::tempdir().expect("create temp dir"); @@ -251,9 +182,6 @@ pub struct Permissions { pub windows_sandbox_mode: Option, /// Whether the final Windows sandboxed child should run on a private desktop. pub windows_sandbox_private_desktop: bool, - /// Optional macOS seatbelt extension profile used to extend default - /// seatbelt permissions when running under seatbelt. - pub macos_seatbelt_profile_extensions: Option, } /// Application configuration loaded from disk and merged with overrides. @@ -316,14 +244,27 @@ pub struct Config { pub user_instructions: Option, /// Base instructions override. - pub base_instructions: Option, + pub base_instructions: Option>, /// Developer instructions override injected as a separate message. pub developer_instructions: Option, + /// Explicit developer instructions override, preserving `null` as distinct + /// from a missing override. + pub developer_instructions_override: Option>, + /// Guardian-specific developer instructions override from requirements.toml. pub guardian_developer_instructions: Option, + /// Whether to inject the `` developer block. + pub include_permissions_instructions: bool, + + /// Whether to inject the `` developer block. + pub include_apps_instructions: bool, + + /// Whether to inject the `` user block. + pub include_environment_context: bool, + /// Compact prompt override. pub compact_prompt: Option, @@ -376,7 +317,7 @@ pub struct Config { /// Controls whether the TUI uses the terminal's alternate screen buffer. /// - /// This is the same `tui.alternate_screen` value from `config.toml` (see [`Tui`]). + /// This is the same `tui.alternate_screen` value from `config.toml`. /// - `auto` (default): Disable alternate screen in Zellij, enable elsewhere. /// - `always`: Always use alternate screen (original behavior). /// - `never`: Never use alternate screen (inline mode, preserves scrollback). @@ -477,6 +418,10 @@ pub struct Config { /// output will be hyperlinked using the specified URI scheme. pub file_opener: UriBasedFileOpener, + /// Path to the current Codex executable. This cannot be set in the config + /// file: it must be set in code via [`ConfigOverrides`]. + pub codex_self_exe: Option, + /// Path to the `codex-linux-sandbox` executable. This must be set if /// [`codex_sandboxing::SandboxType::LinuxSeccomp`] is used. Note that this /// cannot be set in the config file: it must be set in code via @@ -527,10 +472,6 @@ pub struct Config { /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: String, - /// Experimental / do not use. Overrides the URL used when connecting to - /// a remote exec server. - pub experimental_exec_server_url: Option, - /// Machine-local realtime audio device preferences used by realtime voice. pub realtime_audio: RealtimeAudioConfig, @@ -625,7 +566,21 @@ pub struct Config { pub tool_suggest: ToolSuggestConfig, /// OTEL configuration (exporter type, endpoint, headers, etc.). - pub otel: crate::config::types::OtelConfig, + pub otel: codex_config::types::OtelConfig, +} + +impl AuthManagerConfig for Config { + fn codex_home(&self) -> PathBuf { + self.codex_home.clone() + } + + fn cli_auth_credentials_store_mode(&self) -> AuthCredentialsStoreMode { + self.cli_auth_credentials_store_mode + } + + fn forced_chatgpt_workspace_id(&self) -> Option { + self.forced_chatgpt_workspace_id.clone() + } } #[derive(Debug, Clone, Default)] @@ -725,9 +680,51 @@ impl ConfigBuilder { ) .await } + + #[cfg(test)] + pub(crate) fn without_managed_config_for_tests() -> Self { + Self::default().loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + } } impl Config { + pub fn to_models_manager_config(&self) -> ModelsManagerConfig { + ModelsManagerConfig { + model_context_window: self.model_context_window, + model_auto_compact_token_limit: self.model_auto_compact_token_limit, + tool_output_token_limit: self.tool_output_token_limit, + base_instructions: self.base_instructions.clone().flatten(), + personality_enabled: self.features.enabled(Feature::Personality), + model_supports_reasoning_summaries: self.model_supports_reasoning_summaries, + model_catalog: self.model_catalog.clone(), + } + } + + pub fn to_mcp_config(&self, plugins_manager: &crate::plugins::PluginsManager) -> McpConfig { + let loaded_plugins = plugins_manager.plugins_for_config(self); + let mut configured_mcp_servers = self.mcp_servers.get().clone(); + for (name, plugin_server) in loaded_plugins.effective_mcp_servers() { + configured_mcp_servers.entry(name).or_insert(plugin_server); + } + + McpConfig { + chatgpt_base_url: self.chatgpt_base_url.clone(), + codex_home: self.codex_home.clone(), + mcp_oauth_credentials_store_mode: self.mcp_oauth_credentials_store_mode, + mcp_oauth_callback_port: self.mcp_oauth_callback_port, + mcp_oauth_callback_url: self.mcp_oauth_callback_url.clone(), + skill_mcp_dependency_install_enabled: self + .features + .enabled(Feature::SkillMcpDependencyInstall), + approval_policy: self.permissions.approval_policy.clone(), + codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), + use_legacy_landlock: self.features.use_legacy_landlock(), + apps_enabled: self.features.enabled(Feature::Apps), + configured_mcp_servers, + plugin_capability_summaries: loaded_plugins.capability_summaries().to_vec(), + } + } + /// This is the preferred way to create an instance of [Config]. pub async fn load_with_cli_overrides( cli_overrides: Vec<(String, TomlValue)>, @@ -743,6 +740,15 @@ impl Config { cli_overrides: Vec<(String, TomlValue)>, ) -> std::io::Result { let codex_home = find_codex_home()?; + Self::load_default_with_cli_overrides_for_codex_home(codex_home, cli_overrides).await + } + + /// Load a default configuration for a specific Codex home without reading + /// user, project, or system config layers. + pub async fn load_default_with_cli_overrides_for_codex_home( + codex_home: PathBuf, + cli_overrides: Vec<(String, TomlValue)>, + ) -> std::io::Result { let mut merged = toml::Value::try_from(ConfigToml::default()).map_err(|e| { std::io::Error::new( std::io::ErrorKind::InvalidData, @@ -767,7 +773,7 @@ impl Config { /// designed to use [AskForApproval::Never] exclusively. /// /// Further, [ConfigOverrides] contains some options that are not supported - /// in [ConfigToml], such as `cwd`, `codex_linux_sandbox_exe`, and + /// in [ConfigToml], such as `cwd`, `codex_self_exe`, `codex_linux_sandbox_exe`, and /// `main_execve_wrapper_exe`. pub async fn load_with_cli_overrides_and_harness_overrides( cli_overrides: Vec<(String, TomlValue)>, @@ -1016,7 +1022,7 @@ pub(crate) fn set_project_trust_level_inner( // // [projects] // "/path/to/project" = { trust_level = "trusted" } - let project_key = project_path.to_string_lossy().to_string(); + let project_key = project_trust_key(project_path); // Ensure top-level `projects` exists as a non-inline, explicit table. If it // exists but was previously represented as a non-table (e.g., inline), @@ -1086,26 +1092,7 @@ pub fn set_project_trust_level( /// Save the default OSS provider preference to config.toml pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::Result<()> { - // Validate that the provider is one of the known OSS providers - match provider { - LMSTUDIO_OSS_PROVIDER_ID | OLLAMA_OSS_PROVIDER_ID => { - // Valid provider, continue - } - LEGACY_OLLAMA_CHAT_PROVIDER_ID => { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - OLLAMA_CHAT_PROVIDER_REMOVED_ERROR, - )); - } - _ => { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!( - "Invalid OSS provider '{provider}'. Must be one of: {LMSTUDIO_OSS_PROVIDER_ID}, {OLLAMA_OSS_PROVIDER_ID}" - ), - )); - } - } + codex_config::config_toml::validate_oss_provider(provider)?; use toml_edit::value; let edits = [ConfigEdit::SetPath { @@ -1119,447 +1106,15 @@ pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::R .map_err(|err| std::io::Error::other(format!("failed to persist config.toml: {err}"))) } -/// Base config deserialized from ~/.codex/config.toml. -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct ConfigToml { - /// Optional override of model selection. - pub model: Option, - /// Review model override used by the `/review` feature. - pub review_model: Option, - - /// Provider to use from the model_providers map. - pub model_provider: Option, - - /// Size of the context window for the model, in tokens. - pub model_context_window: Option, - - /// Token usage threshold triggering auto-compaction of conversation history. - pub model_auto_compact_token_limit: Option, - - /// Default approval policy for executing commands. - pub approval_policy: Option, - - /// Configures who approval requests are routed to for review once they have - /// been escalated. This does not disable separate safety checks such as - /// ARC. - pub approvals_reviewer: Option, - - #[serde(default)] - pub shell_environment_policy: ShellEnvironmentPolicyToml, - - /// Whether the model may request a login shell for shell-based tools. - /// Default to `true` - /// - /// If `true`, the model may request a login shell (`login = true`), and - /// omitting `login` defaults to using a login shell. - /// If `false`, the model can never use a login shell: `login = true` - /// requests are rejected, and omitting `login` defaults to a non-login - /// shell. - pub allow_login_shell: Option, - - /// Sandbox mode to use. - pub sandbox_mode: Option, - - /// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`. - pub sandbox_workspace_write: Option, - - /// Default named permissions profile to apply from the `[permissions]` - /// table. - pub default_permissions: Option, - - /// Named permissions profiles. - #[serde(default)] - pub permissions: Option, - - /// Optional external command to spawn for end-user notifications. - #[serde(default)] - pub notify: Option>, - - /// System instructions. - pub instructions: Option, - - /// Developer instructions inserted as a `developer` role message. - #[serde(default)] - pub developer_instructions: Option, - - /// Optional path to a file containing model instructions that will override - /// the built-in instructions for the selected model. Users are STRONGLY - /// DISCOURAGED from using this field, as deviating from the instructions - /// sanctioned by Codex will likely degrade model performance. - pub model_instructions_file: Option, - - /// Compact prompt used for history compaction. - pub compact_prompt: Option, - - /// Optional commit attribution text for commit message co-author trailers. - /// - /// Set to an empty string to disable automatic commit attribution. - pub commit_attribution: Option, - - /// When set, restricts ChatGPT login to a specific workspace identifier. - #[serde(default)] - pub forced_chatgpt_workspace_id: Option, - - /// When set, restricts the login mechanism users may use. - #[serde(default)] - pub forced_login_method: Option, - - /// Preferred backend for storing CLI auth credentials. - /// file (default): Use a file in the Codex home directory. - /// keyring: Use an OS-specific keyring service. - /// auto: Use the keyring if available, otherwise use a file. - #[serde(default)] - pub cli_auth_credentials_store: Option, - - /// Definition for MCP servers that Codex can reach out to for tool calls. - #[serde(default)] - // Uses the raw MCP input shape (custom deserialization) rather than `McpServerConfig`. - #[schemars(schema_with = "crate::config::schema::mcp_servers_schema")] - pub mcp_servers: HashMap, - - /// Preferred backend for storing MCP OAuth credentials. - /// keyring: Use an OS-specific keyring service. - /// https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/oauth.rs#L2 - /// file: Use a file in the Codex home directory. - /// auto (default): Use the OS-specific keyring service if available, otherwise use a file. - #[serde(default)] - pub mcp_oauth_credentials_store: Option, - - /// Optional fixed port for the local HTTP callback server used during MCP OAuth login. - /// When unset, Codex will bind to an ephemeral port chosen by the OS. - pub mcp_oauth_callback_port: Option, - - /// Optional redirect URI to use during MCP OAuth login. - /// When set, this URI is used in the OAuth authorization request instead - /// of the local listener address. The local callback listener still binds - /// to 127.0.0.1 (using `mcp_oauth_callback_port` when provided). - pub mcp_oauth_callback_url: Option, - - /// User-defined provider entries that extend the built-in list. Built-in - /// IDs cannot be overridden. - #[serde(default, deserialize_with = "deserialize_model_providers")] - pub model_providers: HashMap, - - /// Maximum number of bytes to include from an AGENTS.md project doc file. - pub project_doc_max_bytes: Option, - - /// Ordered list of fallback filenames to look for when AGENTS.md is missing. - pub project_doc_fallback_filenames: Option>, - - /// Token budget applied when storing tool/function outputs in the context manager. - pub tool_output_token_limit: Option, - - /// Maximum poll window for background terminal output (`write_stdin`), in milliseconds. - /// Default: `300000` (5 minutes). - pub background_terminal_max_timeout: 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: Option>, - - /// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution. - pub zsh_path: Option, - - /// Profile to use from the `profiles` map. - pub profile: Option, - - /// Named profiles to facilitate switching between different configurations. - #[serde(default)] - pub profiles: HashMap, - - /// Settings that govern if and what will be written to `~/.codex/history.jsonl`. - #[serde(default)] - pub history: Option, - - /// Directory where Codex stores the SQLite state DB. - /// Defaults to `$CODEX_SQLITE_HOME` when set. Otherwise uses `$CODEX_HOME`. - pub sqlite_home: Option, - - /// Directory where Codex writes log files, for example `codex-tui.log`. - /// Defaults to `$CODEX_HOME/log`. - pub log_dir: Option, - - /// Optional URI-based file opener. If set, citations to files in the model - /// output will be hyperlinked using the specified URI scheme. - pub file_opener: Option, - - /// Collection of settings that are specific to the TUI. - pub tui: Option, - - /// When set to `true`, `AgentReasoning` events will be hidden from the - /// UI/output. Defaults to `false`. - pub hide_agent_reasoning: Option, - - /// When set to `true`, `AgentReasoningRawContentEvent` events will be shown in the UI/output. - /// Defaults to `false`. - pub show_raw_agent_reasoning: Option, - - pub model_reasoning_effort: Option, - pub plan_mode_reasoning_effort: Option, - pub model_reasoning_summary: Option, - /// Optional verbosity control for GPT-5 models (Responses API `text.verbosity`). - pub model_verbosity: Option, - - /// Override to force-enable reasoning summaries for the configured model. - pub model_supports_reasoning_summaries: Option, - - /// Optional path to a JSON model catalog (applied on startup only). - /// Per-thread `config` overrides are accepted but do not reapply this (no-ops). - pub model_catalog_json: Option, - - /// Optionally specify a personality for the model - pub personality: Option, - - /// Optional explicit service tier preference for new turns (`fast` or `flex`). - pub service_tier: Option, - - /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). - pub chatgpt_base_url: Option, - - /// Base URL override for the built-in `openai` model provider. - pub openai_base_url: Option, - - /// Experimental / do not use. Overrides the URL used when connecting to - /// a remote exec server. - pub experimental_exec_server_url: Option, - - /// Machine-local realtime audio device preferences used by realtime voice. - #[serde(default)] - pub audio: Option, - - /// Experimental / do not use. Overrides only the realtime conversation - /// websocket transport base URL (the `Op::RealtimeConversation` - /// `/v1/realtime` - /// connection) without changing normal provider HTTP requests. - pub experimental_realtime_ws_base_url: Option, - /// Experimental / do not use. Selects the realtime websocket model/snapshot - /// used for the `Op::RealtimeConversation` connection. - pub experimental_realtime_ws_model: Option, - /// Experimental / do not use. Realtime websocket session selection. - /// `version` controls v1/v2 and `type` controls conversational/transcription. - #[serde(default)] - pub realtime: Option, - /// Experimental / do not use. Overrides only the realtime conversation - /// websocket transport instructions (the `Op::RealtimeConversation` - /// `/ws` session.update instructions) without changing normal prompts. - pub experimental_realtime_ws_backend_prompt: Option, - /// Experimental / do not use. Replaces the synthesized realtime startup - /// context appended to websocket session instructions. An empty string - /// disables startup context injection entirely. - pub experimental_realtime_ws_startup_context: Option, - /// Experimental / do not use. Replaces the built-in realtime start - /// instructions inserted into developer messages when realtime becomes - /// active. - pub experimental_realtime_start_instructions: Option, - pub projects: Option>, - - /// Controls the web search tool mode: disabled, cached, or live. - pub web_search: Option, - - /// Nested tools section for feature toggles - pub tools: Option, - - /// Additional discoverable tools that can be suggested for installation. - pub tool_suggest: Option, - - /// Agent-related settings (thread limits, etc.). - pub agents: Option, - - /// Memories subsystem settings. - pub memories: Option, - - /// User-level skill config entries keyed by SKILL.md path. - pub skills: Option, - - /// User-level plugin config entries keyed by plugin name. - #[serde(default)] - pub plugins: HashMap, - - /// Centralized feature flags (new). Prefer this over individual toggles. - #[serde(default)] - // Injects known feature keys into the schema and forbids unknown keys. - #[schemars(schema_with = "crate::config::schema::features_schema")] - pub features: Option, - - /// Suppress warnings about unstable (under development) features. - pub suppress_unstable_features_warning: Option, - - /// Settings for ghost snapshots (used for undo). - #[serde(default)] - pub ghost_snapshot: Option, - - /// Markers used to detect the project root when searching parent - /// directories for `.codex` folders. Defaults to [".git"] when unset. - #[serde(default)] - pub project_root_markers: Option>, - - /// When `true`, checks for Codex updates on startup and surfaces update prompts. - /// Set to `false` only if your Codex updates are centrally managed. - /// Defaults to `true`. - pub check_for_update_on_startup: Option, - - /// When true, disables burst-paste detection for typed input entirely. - /// All characters are inserted as they are received, and no buffering - /// or placeholder replacement will occur for fast keypress bursts. - pub disable_paste_burst: Option, - - /// When `false`, disables analytics across Codex product surfaces in this machine. - /// Defaults to `true`. - pub analytics: Option, - - /// When `false`, disables feedback collection across Codex product surfaces. - /// Defaults to `true`. - pub feedback: Option, - - /// Settings for app-specific controls. - #[serde(default)] - pub apps: Option, - - /// OTEL configuration. - pub otel: Option, - - /// Windows-specific configuration. - #[serde(default)] - pub windows: Option, - - /// Tracks whether the Windows onboarding screen has been acknowledged. - pub windows_wsl_setup_acknowledged: Option, - - /// Collection of in-product notices (different from notifications) - /// See [`crate::config::types::Notices`] for more details - pub notice: Option, - - /// Legacy, now use features - /// Deprecated: ignored. Use `model_instructions_file`. - #[schemars(skip)] - pub experimental_instructions_file: Option, - pub experimental_compact_prompt_file: Option, - pub experimental_use_unified_exec_tool: Option, - pub experimental_use_freeform_apply_patch: Option, - /// Preferred OSS provider for local models, e.g. "lmstudio" or "ollama". - pub oss_provider: Option, -} - -impl From for UserSavedConfig { - fn from(config_toml: ConfigToml) -> Self { - let profiles = config_toml - .profiles - .into_iter() - .map(|(k, v)| (k, v.into())) - .collect(); - - Self { - approval_policy: config_toml.approval_policy, - sandbox_mode: config_toml.sandbox_mode, - sandbox_settings: config_toml.sandbox_workspace_write.map(From::from), - forced_chatgpt_workspace_id: config_toml.forced_chatgpt_workspace_id, - forced_login_method: config_toml.forced_login_method, - model: config_toml.model, - model_reasoning_effort: config_toml.model_reasoning_effort, - model_reasoning_summary: config_toml.model_reasoning_summary, - model_verbosity: config_toml.model_verbosity, - tools: config_toml.tools.map(From::from), - profile: config_toml.profile, - profiles, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct ProjectConfig { - pub trust_level: Option, -} - -impl ProjectConfig { - pub fn is_trusted(&self) -> bool { - matches!(self.trust_level, Some(TrustLevel::Trusted)) - } - - pub fn is_untrusted(&self) -> bool { - matches!(self.trust_level, Some(TrustLevel::Untrusted)) - } -} - #[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct RealtimeAudioConfig { - pub microphone: Option, - pub speaker: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum RealtimeWsMode { - #[default] - Conversational, - Transcription, -} - -pub use codex_protocol::protocol::RealtimeConversationVersion as RealtimeWsVersion; - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct RealtimeConfig { - pub version: RealtimeWsVersion, - #[serde(rename = "type")] - pub session_type: RealtimeWsMode, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct RealtimeToml { - pub version: Option, - #[serde(rename = "type")] - pub session_type: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct RealtimeAudioToml { - pub microphone: Option, - pub speaker: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct ToolsToml { - #[serde( - default, - deserialize_with = "deserialize_optional_web_search_tool_config" - )] - pub web_search: Option, - - /// Enable the `view_image` tool that lets the agent attach local images. - #[serde(default)] - pub view_image: Option, -} - -#[derive(Deserialize)] -#[serde(untagged)] -enum WebSearchToolConfigInput { - Enabled(bool), - Config(WebSearchToolConfig), -} - -fn deserialize_optional_web_search_tool_config<'de, D>( - deserializer: D, -) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - let value = Option::::deserialize(deserializer)?; - - Ok(match value { - None => None, - Some(WebSearchToolConfigInput::Enabled(enabled)) => { - let _ = enabled; - None - } - Some(WebSearchToolConfigInput::Config(config)) => Some(config), - }) +pub struct AgentRoleConfig { + /// Human-facing role documentation used in spawn tool guidance. + /// Required for loaded user-defined roles after deprecated/new metadata precedence resolves. + pub description: Option, + /// Path to a role-specific config layer. + pub config_file: Option, + /// Candidate nicknames for agents spawned with this role. + pub nickname_candidates: Option>, } fn resolve_tool_suggest_config(config_toml: &ConfigToml) -> ToolSuggestConfig { @@ -1584,211 +1139,6 @@ fn resolve_tool_suggest_config(config_toml: &ConfigToml) -> ToolSuggestConfig { ToolSuggestConfig { discoverables } } -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct AgentsToml { - /// Maximum number of agent threads that can be open concurrently. - /// When unset, no limit is enforced. - #[schemars(range(min = 1))] - pub max_threads: Option, - /// Maximum nesting depth allowed for spawned agent threads. - /// Root sessions start at depth 0. - #[schemars(range(min = 1))] - pub max_depth: Option, - /// Default maximum runtime in seconds for agent job workers. - #[schemars(range(min = 1))] - pub job_max_runtime_seconds: Option, - - /// User-defined role declarations keyed by role name. - /// - /// Example: - /// ```toml - /// [agents.researcher] - /// description = "Research-focused role." - /// config_file = "./agents/researcher.toml" - /// nickname_candidates = ["Herodotus", "Ibn Battuta"] - /// ``` - #[serde(default, flatten)] - pub roles: BTreeMap, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct AgentRoleConfig { - /// Human-facing role documentation used in spawn tool guidance. - /// Required for loaded user-defined roles after deprecated/new metadata precedence resolves. - pub description: Option, - /// Path to a role-specific config layer. - pub config_file: Option, - /// Candidate nicknames for agents spawned with this role. - pub nickname_candidates: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct AgentRoleToml { - /// Human-facing role documentation used in spawn tool guidance. - /// Required unless supplied by the referenced agent role file. - pub description: Option, - - /// Path to a role-specific config layer. - /// Relative paths are resolved relative to the `config.toml` that defines them. - pub config_file: Option, - - /// Candidate nicknames for agents spawned with this role. - pub nickname_candidates: Option>, -} - -impl From for Tools { - fn from(tools_toml: ToolsToml) -> Self { - Self { - web_search: tools_toml.web_search.is_some().then_some(true), - view_image: tools_toml.view_image, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct GhostSnapshotToml { - /// Exclude untracked files larger than this many bytes from ghost snapshots. - #[serde(alias = "ignore_untracked_files_over_bytes")] - pub ignore_large_untracked_files: Option, - /// Ignore untracked directories that contain this many files or more. - /// (Still emits a warning unless warnings are disabled.) - #[serde(alias = "large_untracked_dir_warning_threshold")] - pub ignore_large_untracked_dirs: Option, - /// Disable all ghost snapshot warning events. - pub disable_warnings: Option, -} - -impl ConfigToml { - /// Derive the effective sandbox policy from the configuration. - async fn derive_sandbox_policy( - &self, - sandbox_mode_override: Option, - profile_sandbox_mode: Option, - windows_sandbox_level: WindowsSandboxLevel, - resolved_cwd: &Path, - sandbox_policy_constraint: Option<&Constrained>, - ) -> SandboxPolicy { - let sandbox_mode_was_explicit = sandbox_mode_override.is_some() - || profile_sandbox_mode.is_some() - || self.sandbox_mode.is_some(); - let trust_default_sandbox_mode = if sandbox_mode_was_explicit { - None - } else { - self.get_active_project(resolved_cwd) - .await - .and_then(|project| { - if project.is_trusted() || project.is_untrusted() { - if cfg!(target_os = "windows") - && windows_sandbox_level - == codex_protocol::config_types::WindowsSandboxLevel::Disabled - { - Some(SandboxMode::ReadOnly) - } else { - Some(SandboxMode::WorkspaceWrite) - } - } else { - None - } - }) - }; - let resolved_sandbox_mode = sandbox_mode_override - .or(profile_sandbox_mode) - .or(self.sandbox_mode) - .or(trust_default_sandbox_mode) - .unwrap_or_default(); - let mut sandbox_policy = match resolved_sandbox_mode { - SandboxMode::ReadOnly => SandboxPolicy::new_read_only_policy(), - SandboxMode::WorkspaceWrite => match self.sandbox_workspace_write.as_ref() { - Some(SandboxWorkspaceWrite { - writable_roots, - network_access, - exclude_tmpdir_env_var, - exclude_slash_tmp, - }) => SandboxPolicy::WorkspaceWrite { - writable_roots: writable_roots.clone(), - read_only_access: ReadOnlyAccess::FullAccess, - network_access: *network_access, - exclude_tmpdir_env_var: *exclude_tmpdir_env_var, - exclude_slash_tmp: *exclude_slash_tmp, - }, - None => SandboxPolicy::new_workspace_write_policy(), - }, - SandboxMode::DangerFullAccess => SandboxPolicy::DangerFullAccess, - }; - let downgrade_workspace_write_if_unsupported = |policy: &mut SandboxPolicy| { - if cfg!(target_os = "windows") - // If the experimental Windows sandbox is enabled, do not force a downgrade. - && windows_sandbox_level - == codex_protocol::config_types::WindowsSandboxLevel::Disabled - && matches!(&*policy, SandboxPolicy::WorkspaceWrite { .. }) - { - *policy = SandboxPolicy::new_read_only_policy(); - } - }; - if matches!(resolved_sandbox_mode, SandboxMode::WorkspaceWrite) { - downgrade_workspace_write_if_unsupported(&mut sandbox_policy); - } - if !sandbox_mode_was_explicit - && let Some(constraint) = sandbox_policy_constraint - && let Err(err) = constraint.can_set(&sandbox_policy) - { - tracing::warn!( - error = %err, - "default sandbox policy is disallowed by requirements; falling back to required default" - ); - sandbox_policy = constraint.get().clone(); - downgrade_workspace_write_if_unsupported(&mut sandbox_policy); - } - sandbox_policy - } - - /// Resolves the cwd to an existing project, or returns None if ConfigToml - /// does not contain a project corresponding to cwd or a git repo for cwd - pub async fn get_active_project(&self, resolved_cwd: &Path) -> Option { - let projects = self.projects.clone().unwrap_or_default(); - - if let Some(project_config) = projects.get(&resolved_cwd.to_string_lossy().to_string()) { - return Some(project_config.clone()); - } - - // If cwd lives inside a git repo/worktree, check whether the root git project - // (the primary repository working directory) is trusted. This lets - // worktrees inherit trust from the main project. - if let Some(repo_root) = resolve_root_git_project_for_trust(resolved_cwd).await - && let Some(project_config_for_root) = - projects.get(&repo_root.to_string_lossy().to_string_lossy().to_string()) - { - return Some(project_config_for_root.clone()); - } - - None - } - - pub fn get_config_profile( - &self, - override_profile: Option, - ) -> Result { - let profile = override_profile.or_else(|| self.profile.clone()); - - match profile { - Some(key) => { - if let Some(profile) = self.profiles.get(key.as_str()) { - return Ok(profile.clone()); - } - - Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("config profile `{key}` not found"), - )) - } - None => Ok(ConfigProfile::default()), - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PermissionConfigSyntax { Legacy, @@ -1851,13 +1201,14 @@ pub struct ConfigOverrides { pub model_provider: Option, pub service_tier: Option>, pub config_profile: Option, + pub codex_self_exe: Option, pub codex_linux_sandbox_exe: Option, pub main_execve_wrapper_exe: Option, pub js_repl_node_path: Option, pub js_repl_node_module_dirs: Option>, pub zsh_path: Option, - pub base_instructions: Option, - pub developer_instructions: Option, + pub base_instructions: Option>, + pub developer_instructions: Option>, pub personality: Option, pub compact_prompt: Option, pub include_apply_patch_tool: Option, @@ -1868,37 +1219,6 @@ pub struct ConfigOverrides { pub additional_writable_roots: Vec, } -fn validate_reserved_model_provider_ids( - model_providers: &HashMap, -) -> Result<(), String> { - let mut conflicts = model_providers - .keys() - .filter(|key| RESERVED_MODEL_PROVIDER_IDS.contains(&key.as_str())) - .map(|key| format!("`{key}`")) - .collect::>(); - conflicts.sort_unstable(); - if conflicts.is_empty() { - Ok(()) - } else { - Err(format!( - "model_providers contains reserved built-in provider IDs: {}. \ -Built-in providers cannot be overridden. Rename your custom provider (for example, `openai-custom`).", - conflicts.join(", ") - )) - } -} - -fn deserialize_model_providers<'de, D>( - deserializer: D, -) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - let model_providers = HashMap::::deserialize(deserializer)?; - validate_reserved_model_provider_ids(&model_providers).map_err(serde::de::Error::custom)?; - Ok(model_providers) -} - /// Resolves the OSS provider from CLI override, profile config, or global config. /// Returns `None` if no provider is configured at any level. pub fn resolve_oss_provider( @@ -2020,12 +1340,13 @@ impl Config { codex_home: PathBuf, config_layer_stack: ConfigLayerStack, ) -> std::io::Result { - validate_reserved_model_provider_ids(&cfg.model_providers) + validate_model_providers(&cfg.model_providers) .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; // Ensure that every field of ConfigRequirements is applied to the final // Config. let ConfigRequirements { approval_policy: mut constrained_approval_policy, + approvals_reviewer: mut constrained_approvals_reviewer, sandbox_policy: mut constrained_sandbox_policy, web_search_mode: mut constrained_web_search_mode, feature_requirements, @@ -2049,6 +1370,7 @@ impl Config { model_provider, service_tier: service_tier_override, config_profile: config_profile_key, + codex_self_exe, codex_linux_sandbox_exe, main_execve_wrapper_exe, js_repl_node_path: js_repl_node_path_override, @@ -2130,7 +1452,7 @@ impl Config { let mut additional_writable_roots: Vec = additional_writable_roots .into_iter() .map(|path| AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path())) - .collect::, _>>()?; + .collect(); let active_project = cfg .get_active_project(&resolved_cwd) .await @@ -2276,10 +1598,22 @@ impl Config { ); approval_policy = constrained_approval_policy.value(); } - let approvals_reviewer = approvals_reviewer_override + let approvals_reviewer_was_explicit = approvals_reviewer_override.is_some() + || config_profile.approvals_reviewer.is_some() + || cfg.approvals_reviewer.is_some(); + let mut approvals_reviewer = approvals_reviewer_override .or(config_profile.approvals_reviewer) .or(cfg.approvals_reviewer) .unwrap_or(ApprovalsReviewer::User); + if !approvals_reviewer_was_explicit + && let Err(err) = constrained_approvals_reviewer.can_set(&approvals_reviewer) + { + tracing::warn!( + error = %err, + "default approvals reviewer is disallowed by requirements; falling back to required default" + ); + approvals_reviewer = constrained_approvals_reviewer.value(); + } let web_search_mode = resolve_web_search_mode(&cfg, &config_profile, &features) .unwrap_or(WebSearchMode::Cached); let web_search_config = resolve_web_search_config(&cfg, &config_profile); @@ -2291,24 +1625,8 @@ impl Config { .openai_base_url .clone() .filter(|value| !value.is_empty()); - let openai_base_url_from_env = std::env::var(OPENAI_BASE_URL_ENV_VAR) - .ok() - .filter(|value| !value.is_empty()); - if openai_base_url_from_env.is_some() { - if openai_base_url.is_some() { - tracing::warn!( - env_var = OPENAI_BASE_URL_ENV_VAR, - "deprecated env var is ignored because `openai_base_url` is set in config.toml" - ); - } else { - startup_warnings.push(format!( - "`{OPENAI_BASE_URL_ENV_VAR}` is deprecated. Set `openai_base_url` in config.toml instead." - )); - } - } - let effective_openai_base_url = openai_base_url.or(openai_base_url_from_env); - let mut model_providers = built_in_model_providers(effective_openai_base_url); + let mut model_providers = built_in_model_providers(openai_base_url); // Merge user-defined providers into the built-in list. for (key, provider) in cfg.model_providers.into_iter() { model_providers.entry(key).or_insert(provider); @@ -2452,8 +1770,22 @@ impl Config { .or(cfg.model_instructions_file.as_ref()); let file_base_instructions = Self::try_read_non_empty_file(model_instructions_path, "model instructions file")?; - let base_instructions = base_instructions.or(file_base_instructions); - let developer_instructions = developer_instructions.or(cfg.developer_instructions); + let base_instructions = base_instructions.or_else(|| file_base_instructions.map(Some)); + let developer_instructions_override = developer_instructions.clone(); + let developer_instructions = + developer_instructions.unwrap_or_else(|| cfg.developer_instructions.clone()); + let include_permissions_instructions = config_profile + .include_permissions_instructions + .or(cfg.include_permissions_instructions) + .unwrap_or(true); + let include_apps_instructions = config_profile + .include_apps_instructions + .or(cfg.include_apps_instructions) + .unwrap_or(true); + let include_environment_context = config_profile + .include_environment_context + .or(cfg.include_environment_context) + .unwrap_or(true); let guardian_developer_instructions = guardian_developer_instructions_from_requirements( config_layer_stack.requirements_toml(), ); @@ -2526,6 +1858,12 @@ impl Config { &mut constrained_approval_policy, &mut startup_warnings, )?; + apply_requirement_constrained_value( + "approvals_reviewer", + approvals_reviewer, + &mut constrained_approvals_reviewer, + &mut startup_warnings, + )?; apply_requirement_constrained_value( "sandbox_mode", sandbox_policy, @@ -2610,17 +1948,20 @@ impl Config { shell_environment_policy, windows_sandbox_mode, windows_sandbox_private_desktop, - macos_seatbelt_profile_extensions: None, }, - approvals_reviewer, + approvals_reviewer: constrained_approvals_reviewer.value(), enforce_residency: enforce_residency.value, notify: cfg.notify, user_instructions, base_instructions, personality, developer_instructions, + developer_instructions_override, compact_prompt, commit_attribution, + include_permissions_instructions, + include_apps_instructions, + include_environment_context, // The config.toml omits "_mode" because it's a config file. However, "_mode" // is important in code to differentiate the mode from the store implementation. cli_auth_credentials_store_mode: cfg.cli_auth_credentials_store.unwrap_or_default(), @@ -2658,6 +1999,7 @@ impl Config { history, ephemeral: ephemeral.unwrap_or_default(), file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode), + codex_self_exe, codex_linux_sandbox_exe, main_execve_wrapper_exe, js_repl_node_path, @@ -2686,7 +2028,6 @@ impl Config { .chatgpt_base_url .or(cfg.chatgpt_base_url) .unwrap_or("https://chatgpt.com/backend-api/".to_string()), - experimental_exec_server_url: cfg.experimental_exec_server_url, realtime_audio: cfg .audio .map_or_else(RealtimeAudioConfig::default, |audio| RealtimeAudioConfig { diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index 386100d34b..0a37f46cdf 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -20,6 +20,8 @@ use codex_protocol::protocol::SandboxPolicy; use std::collections::HashSet; use std::sync::Arc; +const GLOBAL_ALLOWLIST_PATTERN: &str = "*"; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct NetworkProxySpec { config: NetworkProxyConfig, @@ -195,6 +197,8 @@ impl NetworkProxySpec { let allowlist_expansion_enabled = Self::allowlist_expansion_enabled(sandbox_policy, hard_deny_allowlist_misses); let denylist_expansion_enabled = Self::denylist_expansion_enabled(sandbox_policy); + let danger_full_access_denylist_only = + Self::danger_full_access_denylist_only_enabled(requirements, sandbox_policy); if let Some(enabled) = requirements.enabled { config.network.enabled = enabled; @@ -225,40 +229,84 @@ impl NetworkProxySpec { constraints.dangerously_allow_all_unix_sockets = Some(dangerously_allow_all_unix_sockets); } - let managed_allowed_domains = if hard_deny_allowlist_misses { - Some(requirements.allowed_domains.clone().unwrap_or_default()) + if danger_full_access_denylist_only { + config + .network + .set_allowed_domains(vec![GLOBAL_ALLOWLIST_PATTERN.to_string()]); } else { - requirements.allowed_domains.clone() - }; - if let Some(allowed_domains) = managed_allowed_domains { - // Managed requirements seed the baseline allowlist. User additions - // can extend that baseline unless managed-only mode pins the - // effective allowlist to the managed set. - config.network.allowed_domains = if allowlist_expansion_enabled { - Self::merge_domain_lists(allowed_domains.clone(), &config.network.allowed_domains) + let managed_allowed_domains = if hard_deny_allowlist_misses { + Some( + requirements + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains) + .unwrap_or_default(), + ) } else { - allowed_domains.clone() + requirements + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains) }; - constraints.allowed_domains = Some(allowed_domains); - constraints.allowlist_expansion_enabled = Some(allowlist_expansion_enabled); + if let Some(managed_allowed_domains) = managed_allowed_domains { + // Managed requirements seed the baseline allowlist. User additions + // can extend that baseline unless managed-only mode pins the + // effective allowlist to the managed set. + let effective_allowed_domains = if allowlist_expansion_enabled { + Self::merge_domain_lists( + managed_allowed_domains.clone(), + config.network.allowed_domains().as_deref().unwrap_or(&[]), + ) + } else { + managed_allowed_domains.clone() + }; + config + .network + .set_allowed_domains(effective_allowed_domains); + constraints.allowed_domains = Some(managed_allowed_domains); + constraints.allowlist_expansion_enabled = Some(allowlist_expansion_enabled); + } } - if let Some(denied_domains) = requirements.denied_domains.clone() { - config.network.denied_domains = if denylist_expansion_enabled { - Self::merge_domain_lists(denied_domains.clone(), &config.network.denied_domains) + let managed_denied_domains = requirements + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::denied_domains); + if let Some(managed_denied_domains) = managed_denied_domains { + let effective_denied_domains = if denylist_expansion_enabled { + Self::merge_domain_lists( + managed_denied_domains.clone(), + config.network.denied_domains().as_deref().unwrap_or(&[]), + ) } else { - denied_domains.clone() + managed_denied_domains.clone() }; - constraints.denied_domains = Some(denied_domains); + config.network.set_denied_domains(effective_denied_domains); + constraints.denied_domains = Some(managed_denied_domains); constraints.denylist_expansion_enabled = Some(denylist_expansion_enabled); } - if let Some(allow_unix_sockets) = requirements.allow_unix_sockets.clone() { - config.network.allow_unix_sockets = allow_unix_sockets.clone(); + if requirements.unix_sockets.is_some() && !danger_full_access_denylist_only { + let allow_unix_sockets = requirements + .unix_sockets + .as_ref() + .map(codex_config::NetworkUnixSocketPermissionsToml::allow_unix_sockets) + .unwrap_or_default(); + config + .network + .set_allow_unix_sockets(allow_unix_sockets.clone()); constraints.allow_unix_sockets = Some(allow_unix_sockets); } if let Some(allow_local_binding) = requirements.allow_local_binding { config.network.allow_local_binding = allow_local_binding; constraints.allow_local_binding = Some(allow_local_binding); } + if danger_full_access_denylist_only { + config.network.allow_upstream_proxy = true; + constraints.allow_upstream_proxy = Some(true); + config.network.dangerously_allow_all_unix_sockets = true; + constraints.dangerously_allow_all_unix_sockets = Some(true); + config.network.allow_local_binding = true; + constraints.allow_local_binding = Some(true); + } (config, constraints) } @@ -277,6 +325,16 @@ impl NetworkProxySpec { requirements.managed_allowed_domains_only.unwrap_or(false) } + fn danger_full_access_denylist_only_enabled( + requirements: &NetworkConstraints, + sandbox_policy: &SandboxPolicy, + ) -> bool { + matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) + && requirements + .danger_full_access_denylist_only + .unwrap_or(false) + } + fn denylist_expansion_enabled(sandbox_policy: &SandboxPolicy) -> bool { matches!( sandbox_policy, @@ -299,37 +357,25 @@ impl NetworkProxySpec { fn apply_exec_policy_network_rules(config: &mut NetworkProxyConfig, exec_policy: &Policy) { let (allowed_domains, denied_domains) = exec_policy.compiled_network_domains(); - upsert_network_domains( - &mut config.network.allowed_domains, - &mut config.network.denied_domains, - allowed_domains, - ); - upsert_network_domains( - &mut config.network.denied_domains, - &mut config.network.allowed_domains, - denied_domains, - ); + upsert_network_domains(config, allowed_domains, /*allow*/ true); + upsert_network_domains(config, denied_domains, /*allow*/ false); } -fn upsert_network_domains( - target: &mut Vec, - opposite: &mut Vec, - hosts: Vec, -) { +fn upsert_network_domains(config: &mut NetworkProxyConfig, hosts: Vec, allow: bool) { let mut incoming = HashSet::new(); - let mut deduped_hosts = Vec::new(); for host in hosts { if incoming.insert(host.clone()) { - deduped_hosts.push(host); + config.network.upsert_domain_permission( + host, + if allow { + codex_network_proxy::NetworkDomainPermission::Allow + } else { + codex_network_proxy::NetworkDomainPermission::Deny + }, + normalize_host, + ); } } - if incoming.is_empty() { - return; - } - - opposite.retain(|entry| !incoming.contains(&normalize_host(entry))); - target.retain(|entry| !incoming.contains(&normalize_host(entry))); - target.extend(deduped_hosts); } #[cfg(test)] 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 4c6e82358e..742512d600 100644 --- a/codex-rs/core/src/config/network_proxy_spec_tests.rs +++ b/codex-rs/core/src/config/network_proxy_spec_tests.rs @@ -1,5 +1,22 @@ use super::*; +use crate::config_loader::NetworkDomainPermissionToml; +use crate::config_loader::NetworkDomainPermissionsToml; +use crate::config_loader::NetworkUnixSocketPermissionToml; +use crate::config_loader::NetworkUnixSocketPermissionsToml; +use codex_network_proxy::NetworkDomainPermission; use pretty_assertions::assert_eq; +use std::collections::BTreeMap; + +fn domain_permissions( + entries: impl IntoIterator, +) -> NetworkDomainPermissionsToml { + NetworkDomainPermissionsToml { + entries: entries + .into_iter() + .map(|(pattern, permission)| (pattern.to_string(), permission)) + .collect(), + } +} #[test] fn build_state_with_audit_metadata_threads_metadata_to_state() { @@ -24,9 +41,14 @@ fn build_state_with_audit_metadata_threads_metadata_to_state() { #[test] fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() { let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["api.example.com".to_string()]; + config + .network + .set_allowed_domains(vec!["api.example.com".to_string()]); let requirements = NetworkConstraints { - allowed_domains: Some(vec!["*.example.com".to_string()]), + domains: Some(domain_permissions([( + "*.example.com", + NetworkDomainPermissionToml::Allow, + )])), ..Default::default() }; @@ -38,8 +60,11 @@ fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() { .expect("config should stay within the managed allowlist"); assert_eq!( - spec.config.network.allowed_domains, - vec!["*.example.com".to_string(), "api.example.com".to_string()] + spec.config.network.allowed_domains(), + Some(vec![ + "*.example.com".to_string(), + "api.example.com".to_string() + ]) ); assert_eq!( spec.constraints.allowed_domains, @@ -48,14 +73,92 @@ fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() { assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(true)); } +#[test] +fn requirements_allowed_domains_do_not_override_user_denies_for_same_pattern() { + let mut config = NetworkProxyConfig::default(); + config + .network + .set_denied_domains(vec!["api.example.com".to_string()]); + let requirements = NetworkConstraints { + domains: Some(domain_permissions([( + "api.example.com", + NetworkDomainPermissionToml::Allow, + )])), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::new_workspace_write_policy(), + ) + .expect("managed allowlist should not erase a user deny"); + + assert_eq!(spec.config.network.allowed_domains(), None); + assert_eq!( + spec.config.network.denied_domains(), + Some(vec!["api.example.com".to_string()]) + ); + assert_eq!( + spec.constraints.allowed_domains, + Some(vec!["api.example.com".to_string()]) + ); +} + +#[test] +fn requirements_allowlist_expansion_keeps_user_entries_mutable() { + 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 spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::new_workspace_write_policy(), + ) + .expect("managed baseline should still allow user edits"); + + let mut candidate = spec.config.clone(); + candidate.network.upsert_domain_permission( + "api.example.com".to_string(), + NetworkDomainPermission::Deny, + normalize_host, + ); + + assert_eq!( + candidate.network.allowed_domains(), + Some(vec!["*.example.com".to_string()]) + ); + assert_eq!( + candidate.network.denied_domains(), + Some(vec!["api.example.com".to_string()]) + ); + validate_policy_against_constraints(&candidate, &spec.constraints) + .expect("user allowlist entries should not become managed constraints"); +} + #[test] fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() { let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["evil.com".to_string()]; - config.network.denied_domains = vec!["more-blocked.example.com".to_string()]; + config + .network + .set_allowed_domains(vec!["evil.com".to_string()]); + config + .network + .set_denied_domains(vec!["more-blocked.example.com".to_string()]); let requirements = NetworkConstraints { - allowed_domains: Some(vec!["*.example.com".to_string()]), - denied_domains: Some(vec!["blocked.example.com".to_string()]), + domains: Some(domain_permissions([ + ("*.example.com", NetworkDomainPermissionToml::Allow), + ("blocked.example.com", NetworkDomainPermissionToml::Deny), + ])), ..Default::default() }; @@ -67,23 +170,169 @@ fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() { .expect("yolo mode should pin the effective policy to the managed baseline"); assert_eq!( - spec.config.network.allowed_domains, - vec!["*.example.com".to_string()] + spec.config.network.allowed_domains(), + Some(vec!["*.example.com".to_string()]) ); assert_eq!( - spec.config.network.denied_domains, - vec!["blocked.example.com".to_string()] + spec.config.network.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) ); assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); assert_eq!(spec.constraints.denylist_expansion_enabled, Some(false)); } +#[test] +fn danger_full_access_denylist_only_allows_all_domains_and_enforces_managed_denies() { + let mut config = NetworkProxyConfig::default(); + config + .network + .set_allowed_domains(vec!["evil.com".to_string()]); + config + .network + .set_denied_domains(vec!["more-blocked.example.com".to_string()]); + let requirements = NetworkConstraints { + allow_upstream_proxy: Some(false), + dangerously_allow_all_unix_sockets: Some(false), + domains: Some(domain_permissions([ + ("*.example.com", NetworkDomainPermissionToml::Allow), + ("blocked.example.com", NetworkDomainPermissionToml::Deny), + ])), + danger_full_access_denylist_only: Some(true), + unix_sockets: Some(NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([( + "/tmp/managed.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + )]), + }), + allow_local_binding: Some(false), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::DangerFullAccess, + ) + .expect("denylist-only yolo mode should allow all domains except managed denies"); + + assert_eq!( + spec.config.network.allowed_domains(), + Some(vec!["*".to_string()]) + ); + assert_eq!( + spec.config.network.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); + assert!(spec.config.network.allow_upstream_proxy); + assert!(spec.config.network.dangerously_allow_all_unix_sockets); + assert!(spec.config.network.allow_local_binding); + assert_eq!(spec.constraints.allow_upstream_proxy, Some(true)); + assert_eq!( + spec.constraints.dangerously_allow_all_unix_sockets, + Some(true) + ); + assert_eq!(spec.constraints.allow_unix_sockets, None); + assert_eq!(spec.constraints.allow_local_binding, Some(true)); + assert_eq!(spec.constraints.allowed_domains, None); + assert_eq!(spec.constraints.allowlist_expansion_enabled, None); + assert_eq!( + spec.constraints.denied_domains, + Some(vec!["blocked.example.com".to_string()]) + ); + assert_eq!(spec.constraints.denylist_expansion_enabled, Some(false)); +} + +#[test] +fn danger_full_access_denylist_only_does_not_change_workspace_write_behavior() { + let mut config = NetworkProxyConfig::default(); + config + .network + .set_allowed_domains(vec!["api.example.com".to_string()]); + config + .network + .set_denied_domains(vec!["blocked.example.com".to_string()]); + let requirements = NetworkConstraints { + allow_upstream_proxy: Some(false), + dangerously_allow_all_unix_sockets: Some(false), + domains: Some(domain_permissions([ + ("*.example.com", NetworkDomainPermissionToml::Allow), + ( + "managed-blocked.example.com", + NetworkDomainPermissionToml::Deny, + ), + ])), + danger_full_access_denylist_only: Some(true), + unix_sockets: Some(NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([( + "/tmp/managed.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + )]), + }), + allow_local_binding: Some(false), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::new_workspace_write_policy(), + ) + .expect("denylist-only yolo flag should not affect workspace-write mode"); + + assert_eq!( + spec.config.network.allowed_domains(), + Some(vec![ + "*.example.com".to_string(), + "api.example.com".to_string() + ]) + ); + assert_eq!( + spec.config.network.denied_domains(), + Some(vec![ + "managed-blocked.example.com".to_string(), + "blocked.example.com".to_string() + ]) + ); + assert!(!spec.config.network.allow_upstream_proxy); + assert!(!spec.config.network.dangerously_allow_all_unix_sockets); + assert_eq!( + spec.config.network.allow_unix_sockets(), + vec!["/tmp/managed.sock".to_string()] + ); + assert!(!spec.config.network.allow_local_binding); + assert_eq!(spec.constraints.allow_upstream_proxy, Some(false)); + assert_eq!( + spec.constraints.dangerously_allow_all_unix_sockets, + Some(false) + ); + assert_eq!( + spec.constraints.allow_unix_sockets, + Some(vec!["/tmp/managed.sock".to_string()]) + ); + assert_eq!(spec.constraints.allow_local_binding, Some(false)); + assert_eq!( + spec.constraints.allowed_domains, + Some(vec!["*.example.com".to_string()]) + ); + assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(true)); + assert_eq!( + spec.constraints.denied_domains, + Some(vec!["managed-blocked.example.com".to_string()]) + ); + assert_eq!(spec.constraints.denylist_expansion_enabled, Some(true)); +} + #[test] fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() { let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["api.example.com".to_string()]; + config + .network + .set_allowed_domains(vec!["api.example.com".to_string()]); let requirements = NetworkConstraints { - allowed_domains: Some(vec!["*.example.com".to_string()]), + domains: Some(domain_permissions([( + "*.example.com", + NetworkDomainPermissionToml::Allow, + )])), managed_allowed_domains_only: Some(true), ..Default::default() }; @@ -96,8 +345,8 @@ fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() { .expect("managed baseline should still load"); assert_eq!( - spec.config.network.allowed_domains, - vec!["*.example.com".to_string()] + spec.config.network.allowed_domains(), + Some(vec!["*.example.com".to_string()]) ); assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); } @@ -105,9 +354,14 @@ fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() { #[test] fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses() { let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["api.example.com".to_string()]; + config + .network + .set_allowed_domains(vec!["api.example.com".to_string()]); let requirements = NetworkConstraints { - allowed_domains: Some(vec!["managed.example.com".to_string()]), + domains: Some(domain_permissions([( + "managed.example.com", + NetworkDomainPermissionToml::Allow, + )])), managed_allowed_domains_only: Some(true), ..Default::default() }; @@ -120,8 +374,8 @@ fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses() .expect("managed-only allowlist should still load"); assert_eq!( - spec.config.network.allowed_domains, - vec!["managed.example.com".to_string()] + spec.config.network.allowed_domains(), + Some(vec!["managed.example.com".to_string()]) ); assert_eq!( spec.constraints.allowed_domains, @@ -134,7 +388,9 @@ fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses() #[test] fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domains() { let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["api.example.com".to_string()]; + config + .network + .set_allowed_domains(vec!["api.example.com".to_string()]); let requirements = NetworkConstraints { managed_allowed_domains_only: Some(true), ..Default::default() @@ -147,7 +403,7 @@ fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domain ) .expect("managed-only mode should treat missing managed allowlist as empty"); - assert!(spec.config.network.allowed_domains.is_empty()); + assert_eq!(spec.config.network.allowed_domains(), None); assert_eq!(spec.constraints.allowed_domains, Some(Vec::new())); assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); assert!(spec.hard_deny_allowlist_misses); @@ -156,7 +412,9 @@ fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domain #[test] fn managed_allowed_domains_only_blocks_all_user_domains_in_full_access_without_managed_list() { let mut config = NetworkProxyConfig::default(); - config.network.allowed_domains = vec!["api.example.com".to_string()]; + config + .network + .set_allowed_domains(vec!["api.example.com".to_string()]); let requirements = NetworkConstraints { managed_allowed_domains_only: Some(true), ..Default::default() @@ -169,18 +427,89 @@ fn managed_allowed_domains_only_blocks_all_user_domains_in_full_access_without_m ) .expect("managed-only mode should treat missing managed allowlist as empty"); - assert!(spec.config.network.allowed_domains.is_empty()); + assert_eq!(spec.config.network.allowed_domains(), None); assert_eq!(spec.constraints.allowed_domains, Some(Vec::new())); assert_eq!(spec.constraints.allowlist_expansion_enabled, Some(false)); assert!(spec.hard_deny_allowlist_misses); } +#[test] +fn deny_only_requirements_do_not_create_allow_constraints_in_full_access() { + let mut config = NetworkProxyConfig::default(); + config + .network + .set_allowed_domains(vec!["api.example.com".to_string()]); + let requirements = NetworkConstraints { + domains: Some(domain_permissions([( + "managed-blocked.example.com", + NetworkDomainPermissionToml::Deny, + )])), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::DangerFullAccess, + ) + .expect("deny-only requirements should not constrain the allowlist"); + + assert_eq!( + spec.config.network.allowed_domains(), + Some(vec!["api.example.com".to_string()]) + ); + assert_eq!(spec.constraints.allowed_domains, None); + assert_eq!(spec.constraints.allowlist_expansion_enabled, None); + assert_eq!( + spec.config.network.denied_domains(), + Some(vec!["managed-blocked.example.com".to_string()]) + ); +} + +#[test] +fn allow_only_requirements_do_not_create_deny_constraints_in_full_access() { + let mut config = NetworkProxyConfig::default(); + config + .network + .set_denied_domains(vec!["blocked.example.com".to_string()]); + let requirements = NetworkConstraints { + domains: Some(domain_permissions([( + "managed.example.com", + NetworkDomainPermissionToml::Allow, + )])), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::DangerFullAccess, + ) + .expect("allow-only requirements should not constrain the denylist"); + + assert_eq!( + spec.config.network.allowed_domains(), + Some(vec!["managed.example.com".to_string()]) + ); + assert_eq!( + spec.config.network.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); + assert_eq!(spec.constraints.denied_domains, None); + assert_eq!(spec.constraints.denylist_expansion_enabled, None); +} + #[test] fn requirements_denied_domains_are_a_baseline_for_default_mode() { let mut config = NetworkProxyConfig::default(); - config.network.denied_domains = vec!["blocked.example.com".to_string()]; + config + .network + .set_denied_domains(vec!["blocked.example.com".to_string()]); let requirements = NetworkConstraints { - denied_domains: Some(vec!["managed-blocked.example.com".to_string()]), + domains: Some(domain_permissions([( + "managed-blocked.example.com", + NetworkDomainPermissionToml::Deny, + )])), ..Default::default() }; @@ -192,11 +521,55 @@ fn requirements_denied_domains_are_a_baseline_for_default_mode() { .expect("default mode should merge managed and user deny entries"); assert_eq!( - spec.config.network.denied_domains, - vec![ + spec.config.network.denied_domains(), + Some(vec![ "managed-blocked.example.com".to_string(), "blocked.example.com".to_string() - ] + ]) + ); + assert_eq!( + spec.constraints.denied_domains, + Some(vec!["managed-blocked.example.com".to_string()]) ); assert_eq!(spec.constraints.denylist_expansion_enabled, Some(true)); } + +#[test] +fn requirements_denylist_expansion_keeps_user_entries_mutable() { + let mut config = NetworkProxyConfig::default(); + config + .network + .set_denied_domains(vec!["blocked.example.com".to_string()]); + let requirements = NetworkConstraints { + domains: Some(domain_permissions([( + "managed-blocked.example.com", + NetworkDomainPermissionToml::Deny, + )])), + ..Default::default() + }; + + let spec = NetworkProxySpec::from_config_and_constraints( + config, + Some(requirements), + &SandboxPolicy::new_workspace_write_policy(), + ) + .expect("managed baseline should still allow user edits"); + + let mut candidate = spec.config.clone(); + candidate.network.upsert_domain_permission( + "blocked.example.com".to_string(), + NetworkDomainPermission::Allow, + normalize_host, + ); + + assert_eq!( + candidate.network.allowed_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); + assert_eq!( + candidate.network.denied_domains(), + Some(vec!["managed-blocked.example.com".to_string()]) + ); + validate_policy_against_constraints(&candidate, &spec.constraints) + .expect("user denylist entries should not become managed constraints"); +} diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index 759c269b76..826d0139bf 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -1,139 +1,22 @@ use std::borrow::Cow; -use std::collections::BTreeMap; use std::io; use std::path::Component; use std::path::Path; use std::path::PathBuf; -use codex_network_proxy::NetworkMode; +use codex_config::permissions_toml::FilesystemPermissionToml; +use codex_config::permissions_toml::NetworkToml; +use codex_config::permissions_toml::PermissionProfileToml; +use codex_config::permissions_toml::PermissionsToml; use codex_network_proxy::NetworkProxyConfig; -use codex_protocol::permissions::FileSystemAccessMode; +#[cfg(test)] +use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission; 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_utils_absolute_path::AbsolutePathBuf; -use schemars::JsonSchema; -use serde::Deserialize; -use serde::Serialize; - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] -pub struct PermissionsToml { - #[serde(flatten)] - pub entries: BTreeMap, -} - -impl PermissionsToml { - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct PermissionProfileToml { - pub filesystem: Option, - pub network: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] -pub struct FilesystemPermissionsToml { - #[serde(flatten)] - pub entries: BTreeMap, -} - -impl FilesystemPermissionsToml { - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] -#[serde(untagged)] -pub enum FilesystemPermissionToml { - Access(FileSystemAccessMode), - Scoped(BTreeMap), -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct NetworkToml { - pub enabled: Option, - pub proxy_url: Option, - pub enable_socks5: Option, - pub socks_url: Option, - pub enable_socks5_udp: Option, - pub allow_upstream_proxy: Option, - pub dangerously_allow_non_loopback_proxy: Option, - pub dangerously_allow_all_unix_sockets: Option, - #[schemars(with = "Option")] - pub mode: Option, - pub allowed_domains: Option>, - pub denied_domains: Option>, - pub allow_unix_sockets: Option>, - pub allow_local_binding: Option, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[serde(rename_all = "lowercase")] -enum NetworkModeSchema { - Limited, - Full, -} - -impl NetworkToml { - pub(crate) fn apply_to_network_proxy_config(&self, config: &mut NetworkProxyConfig) { - if let Some(enabled) = self.enabled { - config.network.enabled = enabled; - } - if let Some(proxy_url) = self.proxy_url.as_ref() { - config.network.proxy_url = proxy_url.clone(); - } - if let Some(enable_socks5) = self.enable_socks5 { - config.network.enable_socks5 = enable_socks5; - } - if let Some(socks_url) = self.socks_url.as_ref() { - config.network.socks_url = socks_url.clone(); - } - if let Some(enable_socks5_udp) = self.enable_socks5_udp { - config.network.enable_socks5_udp = enable_socks5_udp; - } - if let Some(allow_upstream_proxy) = self.allow_upstream_proxy { - config.network.allow_upstream_proxy = allow_upstream_proxy; - } - if let Some(dangerously_allow_non_loopback_proxy) = - self.dangerously_allow_non_loopback_proxy - { - config.network.dangerously_allow_non_loopback_proxy = - dangerously_allow_non_loopback_proxy; - } - if let Some(dangerously_allow_all_unix_sockets) = self.dangerously_allow_all_unix_sockets { - config.network.dangerously_allow_all_unix_sockets = dangerously_allow_all_unix_sockets; - } - if let Some(mode) = self.mode { - config.network.mode = mode; - } - if let Some(allowed_domains) = self.allowed_domains.as_ref() { - config.network.allowed_domains = allowed_domains.clone(); - } - if let Some(denied_domains) = self.denied_domains.as_ref() { - config.network.denied_domains = denied_domains.clone(); - } - if let Some(allow_unix_sockets) = self.allow_unix_sockets.as_ref() { - config.network.allow_unix_sockets = allow_unix_sockets.clone(); - } - if let Some(allow_local_binding) = self.allow_local_binding { - config.network.allow_local_binding = allow_local_binding; - } - } - - pub(crate) fn to_network_proxy_config(&self) -> NetworkProxyConfig { - let mut config = NetworkProxyConfig::default(); - self.apply_to_network_proxy_config(&mut config); - config - } -} pub(crate) fn network_proxy_config_from_profile_network( network: Option<&NetworkToml>, @@ -299,7 +182,7 @@ fn compile_scoped_filesystem_path( let subpath = parse_relative_subpath(subpath)?; let base = parse_absolute_path(path)?; - let path = AbsolutePathBuf::resolve_path_against_base(&subpath, base.as_path())?; + let path = AbsolutePathBuf::resolve_path_against_base(&subpath, base.as_path()); Ok(FileSystemPath::Path { path }) } diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index bf1ab5f9c5..e9219431d7 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -1,7 +1,15 @@ use super::*; use crate::config::Config; use crate::config::ConfigOverrides; -use crate::config::ConfigToml; +use codex_config::config_toml::ConfigToml; +use codex_config::permissions_toml::FilesystemPermissionsToml; +use codex_config::permissions_toml::NetworkDomainPermissionToml; +use codex_config::permissions_toml::NetworkDomainPermissionsToml; +use codex_config::permissions_toml::NetworkToml; +use codex_config::permissions_toml::NetworkUnixSocketPermissionToml; +use codex_config::permissions_toml::NetworkUnixSocketPermissionsToml; +use codex_config::permissions_toml::PermissionProfileToml; +use codex_config::permissions_toml::PermissionsToml; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::collections::BTreeMap; @@ -9,8 +17,10 @@ use tempfile::TempDir; #[test] fn normalize_absolute_path_for_platform_simplifies_windows_verbatim_paths() { - let parsed = - normalize_absolute_path_for_platform(r"\\?\D:\c\x\worktrees\2508\swift-base", true); + let parsed = normalize_absolute_path_for_platform( + r"\\?\D:\c\x\worktrees\2508\swift-base", + /*is_windows*/ true, + ); assert_eq!(parsed, PathBuf::from(r"D:\c\x\worktrees\2508\swift-base")); } @@ -77,3 +87,132 @@ async fn restricted_read_implicitly_allows_helper_executables() -> std::io::Resu Ok(()) } + +#[test] +fn network_toml_ignores_legacy_network_list_keys() { + let parsed = toml::from_str::( + r#" +allowed_domains = ["openai.com"] +"#, + ) + .expect("legacy network list keys should be ignored"); + + assert_eq!(parsed, NetworkToml::default()); +} + +#[test] +fn network_permission_containers_project_allowed_and_denied_entries() { + let domains = NetworkDomainPermissionsToml { + entries: BTreeMap::from([ + ( + "*.openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "blocked.example.com".to_string(), + NetworkDomainPermissionToml::Deny, + ), + ]), + }; + let unix_sockets = NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([ + ( + "/tmp/example.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ( + "/tmp/ignored.sock".to_string(), + NetworkUnixSocketPermissionToml::None, + ), + ]), + }; + + assert_eq!( + domains.allowed_domains(), + Some(vec![ + "*.openai.com".to_string(), + "api.example.com".to_string() + ]) + ); + assert_eq!( + domains.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); + assert_eq!( + NetworkDomainPermissionsToml { + entries: BTreeMap::from([( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + } + .denied_domains(), + None + ); + assert_eq!( + unix_sockets.allow_unix_sockets(), + vec!["/tmp/example.sock".to_string()] + ); +} + +#[test] +fn network_toml_overlays_unix_socket_permissions_by_path() { + let mut config = NetworkProxyConfig::default(); + + NetworkToml { + unix_sockets: Some(NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([ + ( + "/tmp/base.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ( + "/tmp/override.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ]), + }), + ..Default::default() + } + .apply_to_network_proxy_config(&mut config); + + NetworkToml { + unix_sockets: Some(NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([ + ( + "/tmp/extra.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ( + "/tmp/override.sock".to_string(), + NetworkUnixSocketPermissionToml::None, + ), + ]), + }), + ..Default::default() + } + .apply_to_network_proxy_config(&mut config); + + assert_eq!( + config.network.unix_sockets, + Some(codex_network_proxy::NetworkUnixSocketPermissions { + entries: BTreeMap::from([ + ( + "/tmp/base.sock".to_string(), + ProxyNetworkUnixSocketPermission::Allow, + ), + ( + "/tmp/extra.sock".to_string(), + ProxyNetworkUnixSocketPermission::Allow, + ), + ( + "/tmp/override.sock".to_string(), + ProxyNetworkUnixSocketPermission::None, + ), + ]), + }) + ); +} diff --git a/codex-rs/core/src/config/schema.rs b/codex-rs/core/src/config/schema.rs index 102b7da514..9507aff585 100644 --- a/codex-rs/core/src/config/schema.rs +++ b/codex-rs/core/src/config/schema.rs @@ -1,100 +1,6 @@ -use crate::config::ConfigToml; -use crate::config::types::RawMcpServerConfig; -use codex_features::FEATURES; -use codex_features::legacy_feature_keys; -use schemars::r#gen::SchemaGenerator; -use schemars::r#gen::SchemaSettings; -use schemars::schema::InstanceType; -use schemars::schema::ObjectValidation; -use schemars::schema::RootSchema; -use schemars::schema::Schema; -use schemars::schema::SchemaObject; -use serde_json::Map; -use serde_json::Value; -use std::path::Path; - -/// Schema for the `[features]` map with known + legacy keys only. -pub(crate) fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { - let mut object = SchemaObject { - instance_type: Some(InstanceType::Object.into()), - ..Default::default() - }; - - let mut validation = ObjectValidation::default(); - for feature in FEATURES { - validation - .properties - .insert(feature.key.to_string(), schema_gen.subschema_for::()); - } - for legacy_key in legacy_feature_keys() { - validation - .properties - .insert(legacy_key.to_string(), schema_gen.subschema_for::()); - } - validation.additional_properties = Some(Box::new(Schema::Bool(false))); - object.object = Some(Box::new(validation)); - - Schema::Object(object) -} - -/// Schema for the `[mcp_servers]` map using the raw input shape. -pub(crate) fn mcp_servers_schema(schema_gen: &mut SchemaGenerator) -> Schema { - let mut object = SchemaObject { - instance_type: Some(InstanceType::Object.into()), - ..Default::default() - }; - - let validation = ObjectValidation { - additional_properties: Some(Box::new(schema_gen.subschema_for::())), - ..Default::default() - }; - object.object = Some(Box::new(validation)); - - Schema::Object(object) -} - -/// Build the config schema for `config.toml`. -pub fn config_schema() -> RootSchema { - SchemaSettings::draft07() - .with(|settings| { - settings.option_add_null_type = false; - }) - .into_generator() - .into_root_schema_for::() -} - -/// Canonicalize a JSON value by sorting its keys. -fn canonicalize(value: &Value) -> Value { - match value { - Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()), - Value::Object(map) => { - let mut entries: Vec<_> = map.iter().collect(); - entries.sort_by(|(left, _), (right, _)| left.cmp(right)); - let mut sorted = Map::with_capacity(map.len()); - for (key, child) in entries { - sorted.insert(key.clone(), canonicalize(child)); - } - Value::Object(sorted) - } - _ => value.clone(), - } -} - -/// Render the config schema as pretty-printed JSON. -pub fn config_schema_json() -> anyhow::Result> { - let schema = config_schema(); - let value = serde_json::to_value(schema)?; - let value = canonicalize(&value); - let json = serde_json::to_vec_pretty(&value)?; - Ok(json) -} - -/// Write the config schema fixture to disk. -pub fn write_config_schema(out_path: &Path) -> anyhow::Result<()> { - let json = config_schema_json()?; - std::fs::write(out_path, json)?; - Ok(()) -} +use codex_config::schema::canonicalize; +use codex_config::schema::config_schema_json; +use codex_config::schema::write_config_schema; #[cfg(test)] #[path = "schema_tests.rs"] diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index 279af666c1..80d4b6bdd9 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -1,4 +1,3 @@ -use super::ConfigToml; use super::deserialize_config_toml_with_base; use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; @@ -29,6 +28,7 @@ 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::config_toml::ConfigToml; use codex_utils_absolute_path::AbsolutePathBuf; use serde_json::Value as JsonValue; use std::borrow::Cow; @@ -140,6 +140,16 @@ impl ConfigService { } } + #[cfg(test)] + pub(crate) fn without_managed_config_for_tests(codex_home: PathBuf) -> Self { + Self::new( + codex_home, + Vec::new(), + LoaderOverrides::without_managed_config_for_tests(), + CloudRequirementsLoader::default(), + ) + } + pub async fn read( &self, params: ConfigReadParams, @@ -255,8 +265,7 @@ impl ConfigService { edits: Vec<(String, JsonValue, MergeStrategy)>, ) -> Result { let allowed_path = - AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, &self.codex_home) - .map_err(|err| ConfigServiceError::io("failed to resolve user config path", err))?; + AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, &self.codex_home); let provided_path = match file_path { Some(path) => AbsolutePathBuf::from_absolute_path(PathBuf::from(path)) .map_err(|err| ConfigServiceError::io("failed to resolve user config path", err))?, diff --git a/codex-rs/core/src/config/service_tests.rs b/codex-rs/core/src/config/service_tests.rs index a1897c9ddd..ae62326605 100644 --- a/codex-rs/core/src/config/service_tests.rs +++ b/codex-rs/core/src/config/service_tests.rs @@ -74,7 +74,7 @@ unified_exec = true "#; std::fs::write(tmp.path().join(CONFIG_TOML_FILE), original)?; - let service = ConfigService::new_with_defaults(tmp.path().to_path_buf()); + let service = ConfigService::without_managed_config_for_tests(tmp.path().to_path_buf()); service .write_value(ConfigValueWriteParams { file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), @@ -108,7 +108,7 @@ async fn write_value_supports_nested_app_paths() -> Result<()> { let tmp = tempdir().expect("tempdir"); std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "")?; - let service = ConfigService::new_with_defaults(tmp.path().to_path_buf()); + let service = ConfigService::without_managed_config_for_tests(tmp.path().to_path_buf()); service .write_value(ConfigValueWriteParams { file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), @@ -178,12 +178,7 @@ async fn read_includes_origins_and_layers() { let service = ConfigService::new( tmp.path().to_path_buf(), vec![], - LoaderOverrides { - managed_config_path: Some(managed_path.clone()), - #[cfg(target_os = "macos")] - managed_preferences_base64: None, - macos_managed_config_requirements_base64: None, - }, + LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), CloudRequirementsLoader::default(), ); @@ -245,23 +240,23 @@ async fn write_value_succeeds_when_managed_preferences_expand_home_directory_pat let tmp = tempdir().expect("tempdir"); std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"\n")?; - let service = ConfigService::new( - tmp.path().to_path_buf(), - vec![], - LoaderOverrides { - managed_config_path: Some(tmp.path().join("managed_config.toml")), - managed_preferences_base64: Some( - base64::prelude::BASE64_STANDARD.encode( - r#" + let mut loader_overrides = + LoaderOverrides::with_managed_config_path_for_tests(tmp.path().join("managed_config.toml")); + loader_overrides.managed_preferences_base64 = Some( + base64::prelude::BASE64_STANDARD.encode( + r#" sandbox_mode = "workspace-write" [sandbox_workspace_write] writable_roots = ["~/code"] "# - .as_bytes(), - ), - ), - macos_managed_config_requirements_base64: None, - }, + .as_bytes(), + ), + ); + + let service = ConfigService::new( + tmp.path().to_path_buf(), + vec![], + loader_overrides, CloudRequirementsLoader::default(), ); @@ -301,12 +296,7 @@ async fn write_value_reports_override() { let service = ConfigService::new( tmp.path().to_path_buf(), vec![], - LoaderOverrides { - managed_config_path: Some(managed_path.clone()), - #[cfg(target_os = "macos")] - managed_preferences_base64: None, - macos_managed_config_requirements_base64: None, - }, + LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), CloudRequirementsLoader::default(), ); @@ -352,7 +342,7 @@ async fn version_conflict_rejected() { let user_path = tmp.path().join(CONFIG_TOML_FILE); std::fs::write(&user_path, "model = \"user\"").unwrap(); - let service = ConfigService::new_with_defaults(tmp.path().to_path_buf()); + let service = ConfigService::without_managed_config_for_tests(tmp.path().to_path_buf()); let error = service .write_value(ConfigValueWriteParams { file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), @@ -375,7 +365,7 @@ async fn write_value_defaults_to_user_config_path() { let tmp = tempdir().expect("tempdir"); std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap(); - let service = ConfigService::new_with_defaults(tmp.path().to_path_buf()); + let service = ConfigService::without_managed_config_for_tests(tmp.path().to_path_buf()); service .write_value(ConfigValueWriteParams { file_path: None, @@ -405,12 +395,7 @@ async fn invalid_user_value_rejected_even_if_overridden_by_managed() { let service = ConfigService::new( tmp.path().to_path_buf(), vec![], - LoaderOverrides { - managed_config_path: Some(managed_path.clone()), - #[cfg(target_os = "macos")] - managed_preferences_base64: None, - macos_managed_config_requirements_base64: None, - }, + LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), CloudRequirementsLoader::default(), ); @@ -439,7 +424,7 @@ async fn reserved_builtin_provider_override_rejected() { let tmp = tempdir().expect("tempdir"); std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"\n").unwrap(); - let service = ConfigService::new_with_defaults(tmp.path().to_path_buf()); + let service = ConfigService::without_managed_config_for_tests(tmp.path().to_path_buf()); let error = service .write_value(ConfigValueWriteParams { file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), @@ -470,12 +455,7 @@ async fn write_value_rejects_feature_requirement_conflict() { let service = ConfigService::new( tmp.path().to_path_buf(), vec![], - LoaderOverrides { - managed_config_path: None, - #[cfg(target_os = "macos")] - managed_preferences_base64: None, - macos_managed_config_requirements_base64: None, - }, + LoaderOverrides::without_managed_config_for_tests(), CloudRequirementsLoader::new(async { Ok(Some(ConfigRequirementsToml { feature_requirements: Some(crate::config_loader::FeatureRequirementsToml { @@ -521,12 +501,7 @@ async fn write_value_rejects_profile_feature_requirement_conflict() { let service = ConfigService::new( tmp.path().to_path_buf(), vec![], - LoaderOverrides { - managed_config_path: None, - #[cfg(target_os = "macos")] - managed_preferences_base64: None, - macos_managed_config_requirements_base64: None, - }, + LoaderOverrides::without_managed_config_for_tests(), CloudRequirementsLoader::new(async { Ok(Some(ConfigRequirementsToml { feature_requirements: Some(crate::config_loader::FeatureRequirementsToml { @@ -583,12 +558,7 @@ async fn read_reports_managed_overrides_user_and_session_flags() { let service = ConfigService::new( tmp.path().to_path_buf(), cli_overrides, - LoaderOverrides { - managed_config_path: Some(managed_path.clone()), - #[cfg(target_os = "macos")] - managed_preferences_base64: None, - macos_managed_config_requirements_base64: None, - }, + LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), CloudRequirementsLoader::default(), ); @@ -641,12 +611,7 @@ async fn write_value_reports_managed_override() { let service = ConfigService::new( tmp.path().to_path_buf(), vec![], - LoaderOverrides { - managed_config_path: Some(managed_path.clone()), - #[cfg(target_os = "macos")] - managed_preferences_base64: None, - macos_managed_config_requirements_base64: None, - }, + LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()), CloudRequirementsLoader::default(), ); @@ -698,7 +663,7 @@ alpha = "a" std::fs::write(&path, base)?; - let service = ConfigService::new_with_defaults(tmp.path().to_path_buf()); + let service = ConfigService::without_managed_config_for_tests(tmp.path().to_path_buf()); service .write_value(ConfigValueWriteParams { file_path: Some(path.display().to_string()), diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 0ab3bc7f7e..e54314c690 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -5,12 +5,14 @@ mod macos; #[cfg(test)] mod tests; -use crate::config::ConfigToml; use crate::config_loader::layer_io::LoadedConfigLayers; use codex_app_server_protocol::ConfigLayerSource; use codex_config::CONFIG_TOML_FILE; use codex_config::ConfigRequirementsWithSources; +use codex_config::config_toml::ConfigToml; +use codex_config::config_toml::ProjectConfig; use codex_git_utils::resolve_root_git_project_for_trust; +use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::TrustLevel; use codex_protocol::protocol::AskForApproval; @@ -42,7 +44,11 @@ pub use codex_config::LoaderOverrides; pub use codex_config::McpServerIdentity; pub use codex_config::McpServerRequirement; pub use codex_config::NetworkConstraints; +pub use codex_config::NetworkDomainPermissionToml; +pub use codex_config::NetworkDomainPermissionsToml; pub use codex_config::NetworkRequirementsToml; +pub use codex_config::NetworkUnixSocketPermissionToml; +pub use codex_config::NetworkUnixSocketPermissionsToml; pub use codex_config::RequirementSource; pub use codex_config::ResidencyRequirement; pub use codex_config::SandboxModeRequirement; @@ -181,7 +187,7 @@ pub async fn load_config_layers_state( // Add a layer for $CODEX_HOME/config.toml if it exists. Note if the file // exists, but is malformed, then this error should be propagated to the // user. - let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home)?; + let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home); let user_layer = load_config_toml_for_required_layer(&user_file, |config_toml| { ConfigLayerEntry::new( ConfigLayerSource::User { @@ -539,7 +545,7 @@ struct ProjectTrustContext { #[derive(Deserialize)] struct ProjectTrustConfigToml { - projects: Option>, + projects: Option>, } struct ProjectTrustDecision { @@ -555,7 +561,7 @@ impl ProjectTrustDecision { impl ProjectTrustContext { fn decision_for_dir(&self, dir: &AbsolutePathBuf) -> ProjectTrustDecision { - let dir_key = dir.as_path().to_string_lossy().to_string(); + let dir_key = project_trust_key(dir.as_path()); if let Some(trust_level) = self.projects_trust.get(&dir_key).copied() { return ProjectTrustDecision { trust_level: Some(trust_level), @@ -643,15 +649,17 @@ async fn project_trust_context( let project_root = find_project_root(cwd, project_root_markers).await?; let projects = project_trust_config.projects.unwrap_or_default(); - let project_root_key = project_root.as_path().to_string_lossy().to_string(); + let project_root_key = project_trust_key(project_root.as_path()); let repo_root = resolve_root_git_project_for_trust(cwd.as_path()).await; - let repo_root_key = repo_root - .as_ref() - .map(|root| root.to_string_lossy().to_string()); + let repo_root_key = repo_root.as_ref().map(|root| project_trust_key(root)); let projects_trust = projects .into_iter() - .filter_map(|(key, project)| project.trust_level.map(|trust_level| (key, trust_level))) + .filter_map(|(key, project)| { + project + .trust_level + .map(|trust_level| (project_trust_key(Path::new(&key)), trust_level)) + }) .collect(); Ok(ProjectTrustContext { @@ -663,6 +671,16 @@ async fn project_trust_context( }) } +/// Canonicalize the path and convert it to a string to be used as a key in the +/// projects trust map. On Windows, strips UNC, when possible, to try to ensure +/// that different paths that point to the same location have the same key. +pub fn project_trust_key(project_path: &Path) -> String { + normalize_path(project_path) + .unwrap_or_else(|_| project_path.to_path_buf()) + .to_string_lossy() + .to_string() +} + /// Takes a `toml::Value` parsed from a config.toml file and walks through it, /// resolving any `AbsolutePathBuf` fields against `base_dir`, returning a new /// `toml::Value` with the same shape but with paths resolved. @@ -791,7 +809,7 @@ async fn load_project_layers( if dot_codex_abs == codex_home_abs || dot_codex_normalized == codex_home_normalized { continue; } - let config_file = dot_codex_abs.join(CONFIG_TOML_FILE)?; + let config_file = dot_codex_abs.join(CONFIG_TOML_FILE); match tokio::fs::read_to_string(&config_file).await { Ok(contents) => { let config: TomlValue = match toml::from_str(&contents) { @@ -864,6 +882,7 @@ async fn load_project_layers( #[derive(Deserialize, Debug, Clone, Default, PartialEq)] struct LegacyManagedConfigToml { approval_policy: Option, + approvals_reviewer: Option, sandbox_mode: Option, } @@ -873,11 +892,15 @@ impl From for ConfigRequirementsToml { let LegacyManagedConfigToml { approval_policy, + approvals_reviewer, sandbox_mode, } = legacy; if let Some(approval_policy) = approval_policy { config_requirements_toml.allowed_approval_policies = Some(vec![approval_policy]); } + if let Some(approvals_reviewer) = approvals_reviewer { + config_requirements_toml.allowed_approvals_reviewers = Some(vec![approvals_reviewer]); + } if let Some(sandbox_mode) = sandbox_mode { let required_mode: SandboxModeRequirement = sandbox_mode.into(); // Allowing read-only is a requirement for Codex to function correctly. @@ -922,7 +945,7 @@ foo = "xyzzy" expected_toml_value.insert( "model_instructions_file".to_string(), TomlValue::String( - AbsolutePathBuf::resolve_path_against_base("./some_file.md", base_dir)? + AbsolutePathBuf::resolve_path_against_base("./some_file.md", base_dir) .as_path() .to_string_lossy() .to_string(), @@ -941,6 +964,7 @@ foo = "xyzzy" fn legacy_managed_config_backfill_includes_read_only_sandbox_mode() { let legacy = LegacyManagedConfigToml { approval_policy: None, + approvals_reviewer: None, sandbox_mode: Some(SandboxMode::WorkspaceWrite), }; @@ -955,6 +979,22 @@ foo = "xyzzy" ); } + #[test] + fn legacy_managed_config_backfill_includes_approvals_reviewer() { + let legacy = LegacyManagedConfigToml { + approval_policy: None, + approvals_reviewer: Some(ApprovalsReviewer::GuardianSubagent), + sandbox_mode: None, + }; + + let requirements = ConfigRequirementsToml::from(legacy); + + assert_eq!( + requirements.allowed_approvals_reviewers, + Some(vec![ApprovalsReviewer::GuardianSubagent]) + ); + } + #[cfg(windows)] #[test] fn windows_system_requirements_toml_file_uses_expected_suffix() { diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 6a2bc0b6b9..d476b8e6fd 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -2,9 +2,7 @@ use super::LoaderOverrides; use super::load_config_layers_state; use crate::config::ConfigBuilder; use crate::config::ConfigOverrides; -use crate::config::ConfigToml; use crate::config::ConstraintError; -use crate::config::ProjectConfig; use crate::config_loader::CloudRequirementsLoadError; use crate::config_loader::CloudRequirementsLoader; use crate::config_loader::ConfigLayerEntry; @@ -16,6 +14,8 @@ use crate::config_loader::RequirementSource; use crate::config_loader::load_requirements_toml; use crate::config_loader::version_for_toml; use codex_config::CONFIG_TOML_FILE; +use codex_config::config_toml::ConfigToml; +use codex_config::config_toml::ProjectConfig; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WebSearchMode; use codex_protocol::protocol::AskForApproval; @@ -78,7 +78,7 @@ async fn cli_overrides_resolve_relative_paths_against_cwd() -> std::io::Result<( .build() .await?; - let expected = AbsolutePathBuf::resolve_path_against_base("run-logs", cwd_path)?; + let expected = AbsolutePathBuf::resolve_path_against_base("run-logs", cwd_path); assert_eq!(config.log_dir, expected.to_path_buf()); Ok(()) } @@ -115,10 +115,7 @@ async fn returns_config_error_for_invalid_managed_config_toml() { let contents = "model = \"gpt-4\"\ninvalid = ["; std::fs::write(&managed_path, contents).expect("write managed config"); - let overrides = LoaderOverrides { - managed_config_path: Some(managed_path.clone()), - ..Default::default() - }; + let overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()); let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd"); let err = load_config_layers_state( @@ -202,12 +199,7 @@ extra = true ) .expect("write managed config"); - let overrides = LoaderOverrides { - managed_config_path: Some(managed_path), - #[cfg(target_os = "macos")] - managed_preferences_base64: None, - macos_managed_config_requirements_base64: None, - }; + let overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path); let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd"); let state = load_config_layers_state( @@ -239,14 +231,7 @@ async fn returns_empty_when_all_layers_missing() { let tmp = tempdir().expect("tempdir"); let managed_path = tmp.path().join("managed_config.toml"); - let overrides = LoaderOverrides { - managed_config_path: Some(managed_path), - #[cfg(target_os = "macos")] - // Force managed preferences to resolve as empty so this test does not - // inherit non-empty machine-specific managed state. - managed_preferences_base64: Some(String::new()), - macos_managed_config_requirements_base64: None, - }; + let overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path); let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd"); let layers = load_config_layers_state( @@ -265,7 +250,6 @@ async fn returns_empty_when_all_layers_missing() { &ConfigLayerEntry { name: super::ConfigLayerSource::User { file: AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, tmp.path()) - .expect("resolve user config.toml path") }, config: TomlValue::Table(toml::map::Map::new()), raw_toml: None, @@ -337,13 +321,9 @@ value = "managed" flag = false "#; - let overrides = LoaderOverrides { - managed_config_path: Some(managed_path), - managed_preferences_base64: Some( - base64::prelude::BASE64_STANDARD.encode(raw_managed_preferences.as_bytes()), - ), - macos_managed_config_requirements_base64: None, - }; + let mut overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path); + overrides.managed_preferences_base64 = + Some(base64::prelude::BASE64_STANDARD.encode(raw_managed_preferences.as_bytes())); let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd"); let state = load_config_layers_state( @@ -391,23 +371,23 @@ async fn managed_preferences_expand_home_directory_in_workspace_write_roots() -> }; let tmp = tempdir()?; - let config = ConfigBuilder::default() - .codex_home(tmp.path().to_path_buf()) - .fallback_cwd(Some(tmp.path().to_path_buf())) - .loader_overrides(LoaderOverrides { - managed_config_path: Some(tmp.path().join("managed_config.toml")), - managed_preferences_base64: Some( - base64::prelude::BASE64_STANDARD.encode( - r#" + let mut loader_overrides = + LoaderOverrides::with_managed_config_path_for_tests(tmp.path().join("managed_config.toml")); + loader_overrides.managed_preferences_base64 = Some( + base64::prelude::BASE64_STANDARD.encode( + r#" sandbox_mode = "workspace-write" [sandbox_workspace_write] writable_roots = ["~/code"] "# - .as_bytes(), - ), - ), - macos_managed_config_requirements_base64: None, - }) + .as_bytes(), + ), + ); + + let config = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(loader_overrides) .build() .await?; @@ -435,23 +415,23 @@ async fn managed_preferences_requirements_are_applied() -> anyhow::Result<()> { let tmp = tempdir()?; + let mut loader_overrides = + LoaderOverrides::with_managed_config_path_for_tests(tmp.path().join("managed_config.toml")); + loader_overrides.macos_managed_config_requirements_base64 = Some( + base64::prelude::BASE64_STANDARD.encode( + r#" +allowed_approval_policies = ["never"] +allowed_sandbox_modes = ["read-only"] +"# + .as_bytes(), + ), + ); + let state = load_config_layers_state( tmp.path(), Some(AbsolutePathBuf::try_from(tmp.path())?), &[] as &[(String, TomlValue)], - LoaderOverrides { - managed_config_path: Some(tmp.path().join("managed_config.toml")), - managed_preferences_base64: Some(String::new()), - macos_managed_config_requirements_base64: Some( - base64::prelude::BASE64_STANDARD.encode( - r#" -allowed_approval_policies = ["never"] -allowed_sandbox_modes = ["read-only"] -"# - .as_bytes(), - ), - ), - }, + loader_overrides, CloudRequirementsLoader::default(), ) .await?; @@ -498,22 +478,21 @@ async fn managed_preferences_requirements_take_precedence() -> anyhow::Result<() tokio::fs::write(&managed_path, "approval_policy = \"on-request\"\n").await?; + let mut loader_overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path); + loader_overrides.macos_managed_config_requirements_base64 = Some( + base64::prelude::BASE64_STANDARD.encode( + r#" +allowed_approval_policies = ["never"] +"# + .as_bytes(), + ), + ); + let state = load_config_layers_state( tmp.path(), Some(AbsolutePathBuf::try_from(tmp.path())?), &[] as &[(String, TomlValue)], - LoaderOverrides { - managed_config_path: Some(managed_path), - managed_preferences_base64: Some(String::new()), - macos_managed_config_requirements_base64: Some( - base64::prelude::BASE64_STANDARD.encode( - r#" -allowed_approval_policies = ["never"] -"# - .as_bytes(), - ), - ), - }, + loader_overrides, CloudRequirementsLoader::default(), ) .await?; @@ -631,24 +610,24 @@ async fn cloud_requirements_take_precedence_over_mdm_requirements() -> anyhow::R use base64::Engine; let tmp = tempdir()?; + let mut loader_overrides = LoaderOverrides::without_managed_config_for_tests(); + loader_overrides.macos_managed_config_requirements_base64 = Some( + base64::prelude::BASE64_STANDARD.encode( + r#" +allowed_approval_policies = ["on-request"] +"# + .as_bytes(), + ), + ); let state = load_config_layers_state( tmp.path(), Some(AbsolutePathBuf::try_from(tmp.path())?), &[] as &[(String, TomlValue)], - LoaderOverrides { - macos_managed_config_requirements_base64: Some( - base64::prelude::BASE64_STANDARD.encode( - r#" -allowed_approval_policies = ["on-request"] -"# - .as_bytes(), - ), - ), - ..LoaderOverrides::default() - }, + loader_overrides, CloudRequirementsLoader::new(async { Ok(Some(ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, feature_requirements: None, @@ -700,6 +679,7 @@ allowed_approval_policies = ["on-request"] RequirementSource::CloudRequirements, ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, feature_requirements: None, @@ -740,6 +720,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> let requirements = ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, feature_requirements: None, @@ -797,7 +778,7 @@ async fn load_config_layers_fails_when_cloud_requirements_loader_fails() -> anyh CloudRequirementsLoader::new(async { Err(CloudRequirementsLoadError::new( codex_config::CloudRequirementsLoadErrorCode::RequestFailed, - None, + /*status_code*/ None, "cloud requirements failed", )) }), @@ -833,7 +814,13 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> { let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; - make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?; + make_config_for_test( + &codex_home, + &project_root, + TrustLevel::Trusted, + /*project_root_markers*/ None, + ) + .await?; let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; let layers = load_config_layers_state( &codex_home, @@ -899,7 +886,13 @@ model_instructions_file = "child.txt" let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; - make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?; + make_config_for_test( + &codex_home, + &project_root, + TrustLevel::Trusted, + /*project_root_markers*/ None, + ) + .await?; let config = ConfigBuilder::default() .codex_home(codex_home) @@ -911,7 +904,7 @@ model_instructions_file = "child.txt" .await?; assert_eq!( - config.base_instructions.as_deref(), + config.base_instructions.as_ref().and_then(Option::as_deref), Some("child instructions") ); @@ -947,7 +940,7 @@ async fn cli_override_model_instructions_file_sets_base_instructions() -> std::i .await?; assert_eq!( - config.base_instructions.as_deref(), + config.base_instructions.as_ref().and_then(Option::as_deref), Some("cli override instructions") ); @@ -965,7 +958,13 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; - make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?; + make_config_for_test( + &codex_home, + &project_root, + TrustLevel::Trusted, + /*project_root_markers*/ None, + ) + .await?; let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; let layers = load_config_layers_state( &codex_home, @@ -1018,7 +1017,7 @@ async fn codex_home_is_not_loaded_as_project_layer_from_home_dir() -> std::io::R let project_layers: Vec<_> = layers .get_layers( super::ConfigLayerStackOrdering::HighestPrecedenceFirst, - true, + /*include_disabled*/ true, ) .into_iter() .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) @@ -1046,7 +1045,13 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul tokio::fs::write(nested_dot_codex.join(CONFIG_TOML_FILE), "foo = \"child\"\n").await?; tokio::fs::create_dir_all(&project_dot_codex).await?; - make_config_for_test(&project_dot_codex, &project_root, TrustLevel::Trusted, None).await?; + make_config_for_test( + &project_dot_codex, + &project_root, + TrustLevel::Trusted, + /*project_root_markers*/ None, + ) + .await?; let user_config_path = project_dot_codex.join(CONFIG_TOML_FILE); let user_config_contents = tokio::fs::read_to_string(&user_config_path).await?; tokio::fs::write( @@ -1068,7 +1073,7 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul let project_layers: Vec<_> = layers .get_layers( super::ConfigLayerStackOrdering::HighestPrecedenceFirst, - true, + /*include_disabled*/ true, ) .into_iter() .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) @@ -1115,7 +1120,7 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result< &codex_home_untrusted, &project_root, TrustLevel::Untrusted, - None, + /*project_root_markers*/ None, ) .await?; let untrusted_config_path = codex_home_untrusted.join(CONFIG_TOML_FILE); @@ -1137,7 +1142,7 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result< let project_layers_untrusted: Vec<_> = layers_untrusted .get_layers( super::ConfigLayerStackOrdering::HighestPrecedenceFirst, - true, + /*include_disabled*/ true, ) .into_iter() .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) @@ -1175,7 +1180,7 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result< let project_layers_unknown: Vec<_> = layers_unknown .get_layers( super::ConfigLayerStackOrdering::HighestPrecedenceFirst, - true, + /*include_disabled*/ true, ) .into_iter() .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) @@ -1218,7 +1223,13 @@ enabled = false "#, ) .await?; - make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?; + make_config_for_test( + &codex_home, + &project_root, + TrustLevel::Trusted, + /*project_root_markers*/ None, + ) + .await?; let config = ConfigBuilder::default() .codex_home(codex_home) @@ -1303,7 +1314,13 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io:: let config_path = codex_home.join(CONFIG_TOML_FILE); if let Some(trust_level) = trust_level { - make_config_for_test(&codex_home, &project_root, trust_level, None).await?; + make_config_for_test( + &codex_home, + &project_root, + trust_level, + /*project_root_markers*/ None, + ) + .await?; let config_contents = tokio::fs::read_to_string(&config_path).await?; tokio::fs::write(&config_path, format!("foo = \"user\"\n{config_contents}")).await?; } else { @@ -1321,7 +1338,7 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io:: let project_layers: Vec<_> = layers .get_layers( super::ConfigLayerStackOrdering::HighestPrecedenceFirst, - true, + /*include_disabled*/ true, ) .into_iter() .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) @@ -1358,7 +1375,13 @@ async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> std::io let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; - make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?; + make_config_for_test( + &codex_home, + &project_root, + TrustLevel::Trusted, + /*project_root_markers*/ None, + ) + .await?; let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; let cli_overrides = vec![( diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index fdb0f43222..ad3447eaf3 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -16,37 +16,38 @@ pub use codex_app_server_protocol::AppInfo; pub use codex_app_server_protocol::AppMetadata; use codex_connectors::AllConnectorsCacheKey; use codex_connectors::DirectoryListResponse; +use codex_login::token_data::TokenData; use codex_protocol::protocol::SandboxPolicy; +use codex_tools::DiscoverableTool; use rmcp::model::ToolAnnotations; use serde::Deserialize; use serde::de::DeserializeOwned; use tracing::warn; -use crate::AuthManager; -use crate::CodexAuth; -use crate::SandboxState; +use crate::codex::INITIAL_SUBMIT_ID; use crate::config::Config; -use crate::config::types::AppToolApproval; -use crate::config::types::AppsConfigToml; -use crate::config::types::ToolSuggestDiscoverableType; use crate::config_loader::AppsRequirementsToml; -use crate::default_client::create_client; -use crate::default_client::is_first_party_chat_originator; -use crate::default_client::originator; -use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::McpManager; -use crate::mcp::ToolPluginProvenance; -use crate::mcp::auth::compute_auth_statuses; -use crate::mcp::with_codex_apps_mcp; -use crate::mcp_connection_manager::McpConnectionManager; -use crate::mcp_connection_manager::codex_apps_tools_cache_key; use crate::plugins::AppConnectorId; use crate::plugins::PluginsManager; use crate::plugins::list_tool_suggest_discoverable_plugins; -use crate::token_data::TokenData; -use crate::tools::discoverable::DiscoverablePluginInfo; -use crate::tools::discoverable::DiscoverableTool; +use codex_config::types::AppToolApproval; +use codex_config::types::AppsConfigToml; +use codex_config::types::ToolSuggestDiscoverableType; use codex_features::Feature; +use codex_login::AuthManager; +use codex_login::CodexAuth; +use codex_login::default_client::create_client; +use codex_login::default_client::is_first_party_chat_originator; +use codex_login::default_client::originator; +use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_mcp::McpConnectionManager; +use codex_mcp::SandboxState; +use codex_mcp::ToolInfo; +use codex_mcp::ToolPluginProvenance; +use codex_mcp::codex_apps_tools_cache_key; +use codex_mcp::compute_auth_statuses; +use codex_mcp::with_codex_apps_mcp; pub use codex_connectors::CONNECTORS_CACHE_TTL; const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30); @@ -133,7 +134,6 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( .map(DiscoverableTool::from); let discoverable_plugins = list_tool_suggest_discoverable_plugins(config)? .into_iter() - .map(DiscoverablePluginInfo::from) .map(DiscoverableTool::from); Ok(discoverable_connectors .chain(discoverable_plugins) @@ -143,9 +143,13 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( pub async fn list_cached_accessible_connectors_from_mcp_tools( config: &Config, ) -> Option> { - let auth_manager = auth_manager_from_config(config); + let auth_manager = + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false); let auth = auth_manager.auth().await; - if !config.features.apps_enabled_for_auth(auth.as_ref()) { + if !config + .features + .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth)) + { return Some(Vec::new()); } let cache_key = accessible_connectors_cache_key(config, auth.as_ref()); @@ -155,7 +159,7 @@ pub async fn list_cached_accessible_connectors_from_mcp_tools( pub(crate) fn refresh_accessible_connectors_cache_from_mcp_tools( config: &Config, auth: Option<&CodexAuth>, - mcp_tools: &HashMap, + mcp_tools: &HashMap, ) { if !config.features.enabled(Feature::Apps) { return; @@ -182,16 +186,21 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( config: &Config, force_refetch: bool, ) -> anyhow::Result { - let auth_manager = auth_manager_from_config(config); + let auth_manager = + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false); let auth = auth_manager.auth().await; - if !config.features.apps_enabled_for_auth(auth.as_ref()) { + if !config + .features + .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth)) + { return Ok(AccessibleConnectorsStatus { connectors: Vec::new(), codex_apps_ready: true, }); } let cache_key = accessible_connectors_cache_key(config, auth.as_ref()); - let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone()))); + let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone())); + let mcp_manager = McpManager::new(Arc::clone(&plugins_manager)); let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config); if !force_refetch && let Some(cached_connectors) = read_cached_accessible_connectors(&cache_key) { @@ -203,12 +212,8 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( }); } - let mcp_servers = with_codex_apps_mcp( - HashMap::new(), - /*connectors_enabled*/ true, - auth.as_ref(), - config, - ); + let mcp_config = config.to_mcp_config(plugins_manager.as_ref()); + let mcp_servers = with_codex_apps_mcp(HashMap::new(), auth.as_ref(), &mcp_config); if mcp_servers.is_empty() { return Ok(AccessibleConnectorsStatus { connectors: Vec::new(), @@ -234,6 +239,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( config.mcp_oauth_credentials_store_mode, auth_status_entries, &config.permissions.approval_policy, + INITIAL_SUBMIT_ID.to_owned(), tx_event, sandbox_state, config.codex_home.clone(), @@ -419,7 +425,8 @@ async fn list_directory_connectors_for_tool_suggest_with_auth( let token_data = if let Some(auth) = auth { auth.get_token_data().ok() } else { - let auth_manager = auth_manager_from_config(config); + let auth_manager = + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false); auth_manager .auth() .await @@ -494,14 +501,6 @@ async fn chatgpt_get_request_with_token( } } -fn auth_manager_from_config(config: &Config) -> std::sync::Arc { - AuthManager::shared( - config.codex_home.clone(), - /*enable_codex_api_key_env*/ false, - config.cli_auth_credentials_store_mode, - ) -} - pub fn connector_display_label(connector: &AppInfo) -> String { format_connector_label(&connector.name, &connector.id) } @@ -511,7 +510,7 @@ pub fn connector_mention_slug(connector: &AppInfo) -> String { } pub(crate) fn accessible_connectors_from_mcp_tools( - mcp_tools: &HashMap, + mcp_tools: &HashMap, ) -> Vec { // ToolInfo already carries plugin provenance, so app-level plugin sources // can be derived here instead of requiring a separate enrichment pass. @@ -685,10 +684,7 @@ pub(crate) fn app_tool_policy( ) } -pub(crate) fn codex_app_tool_is_enabled( - config: &Config, - tool_info: &crate::mcp_connection_manager::ToolInfo, -) -> bool { +pub(crate) fn codex_app_tool_is_enabled(config: &Config, tool_info: &ToolInfo) -> bool { if tool_info.server_name != CODEX_APPS_MCP_SERVER_NAME { return true; } @@ -719,10 +715,6 @@ pub fn filter_disallowed_connectors(connectors: Vec) -> Vec { filter_disallowed_connectors_for_originator(connectors, originator().value.as_str()) } -pub(crate) fn is_connector_id_allowed(connector_id: &str) -> bool { - is_connector_id_allowed_for_originator(connector_id, originator().value.as_str()) -} - fn filter_disallowed_connectors_for_originator( connectors: Vec, originator_value: &str, diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index 2a98621a8b..7a45a97a1f 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -1,19 +1,19 @@ use super::*; use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; -use crate::config::types::AppConfig; -use crate::config::types::AppToolConfig; -use crate::config::types::AppToolsConfig; -use crate::config::types::AppsDefaultConfig; 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 crate::mcp::CODEX_APPS_MCP_SERVER_NAME; -use crate::mcp_connection_manager::ToolInfo; +use codex_config::types::AppConfig; +use codex_config::types::AppToolConfig; +use codex_config::types::AppToolsConfig; +use codex_config::types::AppsDefaultConfig; use codex_features::Feature; +use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_mcp::ToolInfo; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use rmcp::model::JsonObject; @@ -171,7 +171,7 @@ fn accessible_connectors_from_mcp_tools_carries_plugin_display_names() { codex_app_tool( "calendar_list_events", "calendar", - None, + /*connector_name*/ None, &["sample", "sample"], ), ), @@ -229,8 +229,8 @@ async fn refresh_accessible_connectors_cache_from_mcp_tools_writes_latest_instal .build() .await .expect("config should load"); - let _ = config.features.set_enabled(Feature::Apps, true); - let cache_key = accessible_connectors_cache_key(&config, None); + let _ = config.features.set_enabled(Feature::Apps, /*enabled*/ true); + let cache_key = accessible_connectors_cache_key(&config, /*auth*/ None); let tools = HashMap::from([ ( "mcp__codex_apps__calendar_list_events".to_string(), @@ -253,7 +253,7 @@ async fn refresh_accessible_connectors_cache_from_mcp_tools_writes_latest_instal ]); let cached = with_accessible_connectors_cache_cleared(|| { - refresh_accessible_connectors_cache_from_mcp_tools(&config, None, &tools); + refresh_accessible_connectors_cache_from_mcp_tools(&config, /*auth*/ None, &tools); read_cached_accessible_connectors(&cache_key).expect("cache should be populated") }); @@ -367,8 +367,8 @@ fn app_tool_policy_uses_global_defaults_for_destructive_hints() { Some(&apps_config), Some("calendar"), "events/create", - None, - Some(&annotations(Some(true), None)), + /*tool_title*/ None, + Some(&annotations(Some(true), /*open_world_hint*/ None)), ); assert_eq!( @@ -392,7 +392,7 @@ fn app_is_enabled_uses_default_for_unconfigured_apps() { }; assert!(!app_is_enabled(&apps_config, Some("calendar"))); - assert!(!app_is_enabled(&apps_config, None)); + assert!(!app_is_enabled(&apps_config, /*connector_id*/ None)); } #[test] @@ -518,7 +518,13 @@ enabled = true .await .expect("config should build"); - let policy = app_tool_policy(&config, Some("connector_123123"), "events.list", None, None); + let policy = app_tool_policy( + &config, + Some("connector_123123"), + "events.list", + /*tool_title*/ None, + /*annotations*/ None, + ); assert_eq!( policy, AppToolPolicy { @@ -555,7 +561,13 @@ async fn cloud_requirements_disable_connector_applies_without_user_apps_table() .await .expect("config should build"); - let policy = app_tool_policy(&config, Some("connector_123123"), "events.list", None, None); + let policy = app_tool_policy( + &config, + Some("connector_123123"), + "events.list", + /*tool_title*/ None, + /*annotations*/ None, + ); assert_eq!( policy, AppToolPolicy { @@ -602,7 +614,13 @@ enabled = true .expect("apps config"), ); - let policy = app_tool_policy(&config, Some("connector_123123"), "events.list", None, None); + let policy = app_tool_policy( + &config, + Some("connector_123123"), + "events.list", + /*tool_title*/ None, + /*annotations*/ None, + ); assert_eq!( policy, AppToolPolicy { @@ -637,7 +655,13 @@ async fn local_requirements_disable_connector_applies_without_user_apps_table() ConfigLayerStack::new(Vec::new(), ConfigRequirements::default(), requirements) .expect("requirements stack"); - let policy = app_tool_policy(&config, Some("connector_123123"), "events.list", None, None); + let policy = app_tool_policy( + &config, + Some("connector_123123"), + "events.list", + /*tool_title*/ None, + /*annotations*/ None, + ); assert_eq!( policy, AppToolPolicy { @@ -699,8 +723,10 @@ fn app_tool_policy_honors_default_app_enabled_false() { Some(&apps_config), Some("calendar"), "events/list", - None, - Some(&annotations(None, None)), + /*tool_title*/ None, + Some(&annotations( + /*destructive_hint*/ None, /*open_world_hint*/ None, + )), ); assert_eq!( @@ -737,8 +763,10 @@ fn app_tool_policy_allows_per_app_enable_when_default_is_disabled() { Some(&apps_config), Some("calendar"), "events/list", - None, - Some(&annotations(None, None)), + /*tool_title*/ None, + Some(&annotations( + /*destructive_hint*/ None, /*open_world_hint*/ None, + )), ); assert_eq!( @@ -779,7 +807,7 @@ fn app_tool_policy_per_tool_enabled_true_overrides_app_level_disable_flags() { Some(&apps_config), Some("calendar"), "events/create", - None, + /*tool_title*/ None, Some(&annotations(Some(true), Some(true))), ); @@ -813,7 +841,7 @@ fn app_tool_policy_default_tools_enabled_true_overrides_app_level_tool_hints() { Some(&apps_config), Some("calendar"), "events/create", - None, + /*tool_title*/ None, Some(&annotations(Some(true), Some(true))), ); @@ -847,8 +875,10 @@ fn app_tool_policy_default_tools_enabled_false_overrides_app_level_tool_hints() Some(&apps_config), Some("calendar"), "events/list", - None, - Some(&annotations(None, None)), + /*tool_title*/ None, + Some(&annotations( + /*destructive_hint*/ None, /*open_world_hint*/ None, + )), ); assert_eq!( @@ -883,8 +913,10 @@ fn app_tool_policy_uses_default_tools_approval_mode() { Some(&apps_config), Some("calendar"), "events/list", - None, - Some(&annotations(None, None)), + /*tool_title*/ None, + Some(&annotations( + /*destructive_hint*/ None, /*open_world_hint*/ None, + )), ); assert_eq!( diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 3c508e05ff..9720492c1f 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -51,7 +51,7 @@ fn inter_agent_assistant_msg(text: &str) -> ResponseItem { AgentPath::root().join("worker").unwrap(), Vec::new(), text.to_string(), - true, + /*trigger_turn*/ true, ); ResponseItem::Message { id: None, @@ -246,7 +246,8 @@ fn filters_non_api_messages() { #[test] fn non_last_reasoning_tokens_return_zero_when_no_user_messages() { - let history = create_history_with_items(vec![reasoning_with_encrypted_content(800)]); + let history = + create_history_with_items(vec![reasoning_with_encrypted_content(/*len*/ 800)]); assert_eq!(history.get_non_last_reasoning_items_tokens(), 0); } @@ -254,11 +255,11 @@ fn non_last_reasoning_tokens_return_zero_when_no_user_messages() { #[test] fn non_last_reasoning_tokens_ignore_entries_after_last_user() { let history = create_history_with_items(vec![ - reasoning_with_encrypted_content(900), + reasoning_with_encrypted_content(/*len*/ 900), user_msg("first"), - reasoning_with_encrypted_content(1_000), + reasoning_with_encrypted_content(/*len*/ 1_000), user_msg("second"), - reasoning_with_encrypted_content(2_000), + reasoning_with_encrypted_content(/*len*/ 2_000), ]); // first: (900 * 0.75 - 650) / 4 = 6.25 tokens // second: (1000 * 0.75 - 650) / 4 = 25 tokens @@ -330,7 +331,7 @@ fn drop_last_n_user_turns_treats_inter_agent_assistant_messages_as_instruction_t inter_agent_reply, ]); - history.drop_last_n_user_turns(1); + history.drop_last_n_user_turns(/*num_turns*/ 1); assert_eq!(history.raw_items(), &vec![first_turn, first_reply]); } @@ -352,7 +353,7 @@ fn total_token_usage_includes_all_items_after_last_model_generated_item() { total_tokens: 100, ..Default::default() }, - None, + /*model_context_window*/ None, ); let added_user = user_msg("new user message"); let added_tool_output = custom_tool_call_output("tool-tail", "new tool output"); @@ -362,7 +363,7 @@ fn total_token_usage_includes_all_items_after_last_model_generated_item() { ); assert_eq!( - history.get_total_token_usage(true), + history.get_total_token_usage(/*server_reasoning_included*/ true), 100 + estimate_item_token_count(&added_user) + estimate_item_token_count(&added_tool_output) ); @@ -606,7 +607,12 @@ fn for_prompt_clears_image_generation_result_when_images_are_unsupported() { #[test] fn get_history_for_prompt_drops_ghost_commits() { let items = vec![ResponseItem::GhostSnapshot { - ghost_commit: GhostCommit::new("ghost-1".to_string(), None, Vec::new(), Vec::new()), + ghost_commit: GhostCommit::new( + "ghost-1".to_string(), + /*parent*/ None, + Vec::new(), + Vec::new(), + ), }]; let history = create_history_with_items(items); let modalities = default_input_modalities(); @@ -792,7 +798,7 @@ fn drop_last_n_user_turns_preserves_prefix() { let modalities = default_input_modalities(); let mut history = create_history_with_items(items); - history.drop_last_n_user_turns(1); + history.drop_last_n_user_turns(/*num_turns*/ 1); assert_eq!( history.for_prompt(&modalities), vec![ @@ -809,7 +815,7 @@ fn drop_last_n_user_turns_preserves_prefix() { user_msg("u2"), assistant_msg("a2"), ]); - history.drop_last_n_user_turns(99); + history.drop_last_n_user_turns(/*num_turns*/ 99); assert_eq!( history.for_prompt(&modalities), vec![assistant_msg("session prefix item")] @@ -838,7 +844,7 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() { let modalities = default_input_modalities(); let mut history = create_history_with_items(items); - history.drop_last_n_user_turns(1); + history.drop_last_n_user_turns(/*num_turns*/ 1); let expected_prefix_and_first_turn = vec![ user_input_text_msg("ctx"), @@ -892,7 +898,7 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() { user_input_text_msg("turn 2 user"), assistant_msg("turn 2 assistant"), ]); - history.drop_last_n_user_turns(2); + history.drop_last_n_user_turns(/*num_turns*/ 2); assert_eq!(history.for_prompt(&modalities), expected_prefix_only); let mut history = create_history_with_items(vec![ @@ -912,7 +918,7 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() { user_input_text_msg("turn 2 user"), assistant_msg("turn 2 assistant"), ]); - history.drop_last_n_user_turns(3); + history.drop_last_n_user_turns(/*num_turns*/ 3); assert_eq!(history.for_prompt(&modalities), expected_prefix_only); } @@ -935,7 +941,7 @@ fn drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn() { let mut history = create_history_with_items(items); let reference_context_item = reference_context_item(); history.set_reference_context_item(Some(reference_context_item.clone())); - history.drop_last_n_user_turns(1); + history.drop_last_n_user_turns(/*num_turns*/ 1); assert_eq!( history.clone().for_prompt(&modalities), @@ -973,7 +979,7 @@ fn drop_last_n_user_turns_clears_reference_context_for_mixed_developer_context_b let modalities = default_input_modalities(); let mut history = create_history_with_items(items); history.set_reference_context_item(Some(reference_context_item())); - history.drop_last_n_user_turns(1); + history.drop_last_n_user_turns(/*num_turns*/ 1); assert_eq!( history.clone().for_prompt(&modalities), @@ -1165,7 +1171,7 @@ fn format_exec_output_truncates_large_error() { let truncated = truncate_exec_output(&large_error); - assert_truncated_message_matches(&truncated, line, 36250); + assert_truncated_message_matches(&truncated, line, /*expected_removed*/ 36250); assert_ne!(truncated, large_error); } @@ -1174,7 +1180,7 @@ fn format_exec_output_marks_byte_truncation_without_omitted_lines() { let long_line = "a".repeat(EXEC_FORMAT_MAX_BYTES + 10000); let truncated = truncate_exec_output(&long_line); assert_ne!(truncated, long_line); - assert_truncated_message_matches(&truncated, "a", 2500); + assert_truncated_message_matches(&truncated, "a", /*expected_removed*/ 2500); assert!( !truncated.contains("omitted"), "line omission marker should not appear when no lines were dropped: {truncated}" @@ -1196,7 +1202,7 @@ fn format_exec_output_reports_omitted_lines_and_keeps_head_and_tail() { .collect(); let truncated = truncate_exec_output(&content); - assert_truncated_message_matches(&truncated, "line-0-", 34_723); + assert_truncated_message_matches(&truncated, "line-0-", /*expected_removed*/ 34_723); assert!( truncated.contains("line-0-"), "expected head line to remain: {truncated}" @@ -1219,7 +1225,7 @@ fn format_exec_output_prefers_line_marker_when_both_limits_exceeded() { let truncated = truncate_exec_output(&content); - assert_truncated_message_matches(&truncated, "line-0-", 17_423); + assert_truncated_message_matches(&truncated, "line-0-", /*expected_removed*/ 17_423); } #[cfg(not(debug_assertions))] diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 34fd41c083..e92aad3d77 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -16,6 +16,10 @@ fn build_environment_update_item( next: &TurnContext, shell: &Shell, ) -> Option { + if !next.config.include_environment_context { + return None; + } + let prev = previous?; let prev_context = EnvironmentContext::from_turn_context_item(prev, shell); let next_context = EnvironmentContext::from_turn_context(next, shell); @@ -33,6 +37,10 @@ fn build_permissions_update_item( next: &TurnContext, exec_policy: &Policy, ) -> Option { + if !next.config.include_permissions_instructions { + return None; + } + let prev = previous?; if prev.sandbox_policy == *next.sandbox_policy.get() && prev.approval_policy == next.approval_policy.value() diff --git a/codex-rs/core/src/contextual_user_message_tests.rs b/codex-rs/core/src/contextual_user_message_tests.rs index 93db860ea9..406f03c27f 100644 --- a/codex-rs/core/src/contextual_user_message_tests.rs +++ b/codex-rs/core/src/contextual_user_message_tests.rs @@ -86,8 +86,8 @@ fn detects_hook_prompt_fragment_and_roundtrips_escaping() { let ContentItem::InputText { text } = content_item else { panic!("expected input text content item"); }; - let parsed = - parse_visible_hook_prompt_message(None, content.as_slice()).expect("visible hook prompt"); + let parsed = parse_visible_hook_prompt_message(/*id*/ None, content.as_slice()) + .expect("visible hook prompt"); assert_eq!( parsed.fragments, vec![HookPromptFragment { diff --git a/codex-rs/core/src/custom_prompts.rs b/codex-rs/core/src/custom_prompts.rs deleted file mode 100644 index 54ccaa62fe..0000000000 --- a/codex-rs/core/src/custom_prompts.rs +++ /dev/null @@ -1,149 +0,0 @@ -use codex_protocol::custom_prompts::CustomPrompt; -use std::collections::HashSet; -use std::path::Path; -use std::path::PathBuf; -use tokio::fs; - -/// Return the default prompts directory: `$CODEX_HOME/prompts`. -/// If `CODEX_HOME` cannot be resolved, returns `None`. -pub fn default_prompts_dir() -> Option { - crate::config::find_codex_home() - .ok() - .map(|home| home.join("prompts")) -} - -/// Discover prompt files in the given directory, returning entries sorted by name. -/// Non-files are ignored. If the directory does not exist or cannot be read, returns empty. -pub async fn discover_prompts_in(dir: &Path) -> Vec { - discover_prompts_in_excluding(dir, &HashSet::new()).await -} - -/// Discover prompt files in the given directory, excluding any with names in `exclude`. -/// Returns entries sorted by name. Non-files are ignored. Missing/unreadable dir yields empty. -pub async fn discover_prompts_in_excluding( - dir: &Path, - exclude: &HashSet, -) -> Vec { - let mut out: Vec = Vec::new(); - let mut entries = match fs::read_dir(dir).await { - Ok(entries) => entries, - Err(_) => return out, - }; - - while let Ok(Some(entry)) = entries.next_entry().await { - let path = entry.path(); - let is_file_like = fs::metadata(&path) - .await - .map(|m| m.is_file()) - .unwrap_or(false); - if !is_file_like { - continue; - } - // Only include Markdown files with a .md extension. - let is_md = path - .extension() - .and_then(|s| s.to_str()) - .map(|ext| ext.eq_ignore_ascii_case("md")) - .unwrap_or(false); - if !is_md { - continue; - } - let Some(name) = path - .file_stem() - .and_then(|s| s.to_str()) - .map(str::to_string) - else { - continue; - }; - if exclude.contains(&name) { - continue; - } - let content = match fs::read_to_string(&path).await { - Ok(s) => s, - Err(_) => continue, - }; - let (description, argument_hint, body) = parse_frontmatter(&content); - out.push(CustomPrompt { - name, - path, - content: body, - description, - argument_hint, - }); - } - out.sort_by(|a, b| a.name.cmp(&b.name)); - out -} - -/// Parse optional YAML-like frontmatter at the beginning of `content`. -/// Supported keys: -/// - `description`: short description shown in the slash popup -/// - `argument-hint` or `argument_hint`: brief hint string shown after the description -/// Returns (description, argument_hint, body_without_frontmatter). -fn parse_frontmatter(content: &str) -> (Option, Option, String) { - let mut segments = content.split_inclusive('\n'); - let Some(first_segment) = segments.next() else { - return (None, None, String::new()); - }; - let first_line = first_segment.trim_end_matches(['\r', '\n']); - if first_line.trim() != "---" { - return (None, None, content.to_string()); - } - - let mut desc: Option = None; - let mut hint: Option = None; - let mut frontmatter_closed = false; - let mut consumed = first_segment.len(); - - for segment in segments { - let line = segment.trim_end_matches(['\r', '\n']); - let trimmed = line.trim(); - - if trimmed == "---" { - frontmatter_closed = true; - consumed += segment.len(); - break; - } - - if trimmed.is_empty() || trimmed.starts_with('#') { - consumed += segment.len(); - continue; - } - - if let Some((k, v)) = trimmed.split_once(':') { - let key = k.trim().to_ascii_lowercase(); - let mut val = v.trim().to_string(); - if val.len() >= 2 { - let bytes = val.as_bytes(); - let first = bytes[0]; - let last = bytes[bytes.len() - 1]; - if (first == b'\"' && last == b'\"') || (first == b'\'' && last == b'\'') { - val = val[1..val.len().saturating_sub(1)].to_string(); - } - } - match key.as_str() { - "description" => desc = Some(val), - "argument-hint" | "argument_hint" => hint = Some(val), - _ => {} - } - } - - consumed += segment.len(); - } - - if !frontmatter_closed { - // Unterminated frontmatter: treat input as-is. - return (None, None, content.to_string()); - } - - let body = if consumed >= content.len() { - String::new() - } else { - content[consumed..].to_string() - }; - (desc, hint, body) -} - -#[cfg(test)] -#[path = "custom_prompts_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/custom_prompts_tests.rs b/codex-rs/core/src/custom_prompts_tests.rs deleted file mode 100644 index b1208a04e0..0000000000 --- a/codex-rs/core/src/custom_prompts_tests.rs +++ /dev/null @@ -1,95 +0,0 @@ -use super::*; -use std::fs; -use tempfile::tempdir; - -#[tokio::test] -async fn empty_when_dir_missing() { - let tmp = tempdir().expect("create TempDir"); - let missing = tmp.path().join("nope"); - let found = discover_prompts_in(&missing).await; - assert!(found.is_empty()); -} - -#[tokio::test] -async fn discovers_and_sorts_files() { - let tmp = tempdir().expect("create TempDir"); - let dir = tmp.path(); - fs::write(dir.join("b.md"), b"b").unwrap(); - fs::write(dir.join("a.md"), b"a").unwrap(); - fs::create_dir(dir.join("subdir")).unwrap(); - let found = discover_prompts_in(dir).await; - let names: Vec = found.into_iter().map(|e| e.name).collect(); - assert_eq!(names, vec!["a", "b"]); -} - -#[tokio::test] -async fn excludes_builtins() { - let tmp = tempdir().expect("create TempDir"); - let dir = tmp.path(); - fs::write(dir.join("init.md"), b"ignored").unwrap(); - fs::write(dir.join("foo.md"), b"ok").unwrap(); - let mut exclude = HashSet::new(); - exclude.insert("init".to_string()); - let found = discover_prompts_in_excluding(dir, &exclude).await; - let names: Vec = found.into_iter().map(|e| e.name).collect(); - assert_eq!(names, vec!["foo"]); -} - -#[tokio::test] -async fn skips_non_utf8_files() { - let tmp = tempdir().expect("create TempDir"); - let dir = tmp.path(); - // Valid UTF-8 file - fs::write(dir.join("good.md"), b"hello").unwrap(); - // Invalid UTF-8 content in .md file (e.g., lone 0xFF byte) - fs::write(dir.join("bad.md"), vec![0xFF, 0xFE, b'\n']).unwrap(); - let found = discover_prompts_in(dir).await; - let names: Vec = found.into_iter().map(|e| e.name).collect(); - assert_eq!(names, vec!["good"]); -} - -#[tokio::test] -#[cfg(unix)] -async fn discovers_symlinked_md_files() { - let tmp = tempdir().expect("create TempDir"); - let dir = tmp.path(); - - // Create a real file - fs::write(dir.join("real.md"), b"real content").unwrap(); - - // Create a symlink to the real file - std::os::unix::fs::symlink(dir.join("real.md"), dir.join("link.md")).unwrap(); - - let found = discover_prompts_in(dir).await; - let names: Vec = found.into_iter().map(|e| e.name).collect(); - - // Both real and link should be discovered, sorted alphabetically - assert_eq!(names, vec!["link", "real"]); -} - -#[tokio::test] -async fn parses_frontmatter_and_strips_from_body() { - let tmp = tempdir().expect("create TempDir"); - let dir = tmp.path(); - let file = dir.join("withmeta.md"); - let text = "---\nname: ignored\ndescription: \"Quick review command\"\nargument-hint: \"[file] [priority]\"\n---\nActual body with $1 and $ARGUMENTS"; - fs::write(&file, text).unwrap(); - - let found = discover_prompts_in(dir).await; - assert_eq!(found.len(), 1); - let p = &found[0]; - assert_eq!(p.name, "withmeta"); - assert_eq!(p.description.as_deref(), Some("Quick review command")); - assert_eq!(p.argument_hint.as_deref(), Some("[file] [priority]")); - // Body should not include the frontmatter delimiters. - assert_eq!(p.content, "Actual body with $1 and $ARGUMENTS"); -} - -#[test] -fn parse_frontmatter_preserves_body_newlines() { - let content = "---\r\ndescription: \"Line endings\"\r\nargument_hint: \"[arg]\"\r\n---\r\nFirst line\r\nSecond line\r\n"; - let (desc, hint, body) = parse_frontmatter(content); - assert_eq!(desc.as_deref(), Some("Line endings")); - assert_eq!(hint.as_deref(), Some("[arg]")); - assert_eq!(body, "First line\r\nSecond line\r\n"); -} diff --git a/codex-rs/core/src/default_client_forwarding.rs b/codex-rs/core/src/default_client_forwarding.rs deleted file mode 100644 index 75b76b042c..0000000000 --- a/codex-rs/core/src/default_client_forwarding.rs +++ /dev/null @@ -1,2 +0,0 @@ -// Re-exported as `crate::default_client` from `lib.rs`. -pub use codex_login::default_client::*; diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index bbb39da113..df4e49cf4e 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -130,8 +130,16 @@ impl EnvironmentContext { .as_ref()?; Some(NetworkContext { - allowed_domains: network.allowed_domains.clone().unwrap_or_default(), - denied_domains: network.denied_domains.clone().unwrap_or_default(), + allowed_domains: network + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains) + .unwrap_or_default(), + denied_domains: network + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::denied_domains) + .unwrap_or_default(), }) } diff --git a/codex-rs/core/src/environment_context_tests.rs b/codex-rs/core/src/environment_context_tests.rs index 5718c09de4..073f1fe169 100644 --- a/codex-rs/core/src/environment_context_tests.rs +++ b/codex-rs/core/src/environment_context_tests.rs @@ -20,8 +20,8 @@ fn serialize_workspace_write_environment_context() { fake_shell(), Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), - None, - None, + /*network*/ None, + /*subagents*/ None, ); let expected = format!( @@ -49,7 +49,7 @@ fn serialize_environment_context_with_network() { Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), Some(network), - None, + /*subagents*/ None, ); let expected = format!( @@ -73,12 +73,12 @@ fn serialize_environment_context_with_network() { #[test] fn serialize_read_only_environment_context() { let context = EnvironmentContext::new( - None, + /*cwd*/ None, fake_shell(), Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), - None, - None, + /*network*/ None, + /*subagents*/ None, ); let expected = r#" @@ -93,12 +93,12 @@ fn serialize_read_only_environment_context() { #[test] fn serialize_external_sandbox_environment_context() { let context = EnvironmentContext::new( - None, + /*cwd*/ None, fake_shell(), Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), - None, - None, + /*network*/ None, + /*subagents*/ None, ); let expected = r#" @@ -113,12 +113,12 @@ fn serialize_external_sandbox_environment_context() { #[test] fn serialize_external_sandbox_with_restricted_network_environment_context() { let context = EnvironmentContext::new( - None, + /*cwd*/ None, fake_shell(), Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), - None, - None, + /*network*/ None, + /*subagents*/ None, ); let expected = r#" @@ -133,12 +133,12 @@ fn serialize_external_sandbox_with_restricted_network_environment_context() { #[test] fn serialize_full_access_environment_context() { let context = EnvironmentContext::new( - None, + /*cwd*/ None, fake_shell(), Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), - None, - None, + /*network*/ None, + /*subagents*/ None, ); let expected = r#" @@ -155,18 +155,18 @@ fn equals_except_shell_compares_cwd() { let context1 = EnvironmentContext::new( Some(PathBuf::from("/repo")), fake_shell(), - None, - None, - None, - None, + /*current_date*/ None, + /*timezone*/ None, + /*network*/ None, + /*subagents*/ None, ); let context2 = EnvironmentContext::new( Some(PathBuf::from("/repo")), fake_shell(), - None, - None, - None, - None, + /*current_date*/ None, + /*timezone*/ None, + /*network*/ None, + /*subagents*/ None, ); assert!(context1.equals_except_shell(&context2)); } @@ -176,18 +176,18 @@ fn equals_except_shell_ignores_sandbox_policy() { let context1 = EnvironmentContext::new( Some(PathBuf::from("/repo")), fake_shell(), - None, - None, - None, - None, + /*current_date*/ None, + /*timezone*/ None, + /*network*/ None, + /*subagents*/ None, ); let context2 = EnvironmentContext::new( Some(PathBuf::from("/repo")), fake_shell(), - None, - None, - None, - None, + /*current_date*/ None, + /*timezone*/ None, + /*network*/ None, + /*subagents*/ None, ); assert!(context1.equals_except_shell(&context2)); @@ -198,18 +198,18 @@ fn equals_except_shell_compares_cwd_differences() { let context1 = EnvironmentContext::new( Some(PathBuf::from("/repo1")), fake_shell(), - None, - None, - None, - None, + /*current_date*/ None, + /*timezone*/ None, + /*network*/ None, + /*subagents*/ None, ); let context2 = EnvironmentContext::new( Some(PathBuf::from("/repo2")), fake_shell(), - None, - None, - None, - None, + /*current_date*/ None, + /*timezone*/ None, + /*network*/ None, + /*subagents*/ None, ); assert!(!context1.equals_except_shell(&context2)); @@ -224,10 +224,10 @@ fn equals_except_shell_ignores_shell() { shell_path: "/bin/bash".into(), shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }, - None, - None, - None, - None, + /*current_date*/ None, + /*timezone*/ None, + /*network*/ None, + /*subagents*/ None, ); let context2 = EnvironmentContext::new( Some(PathBuf::from("/repo")), @@ -236,10 +236,10 @@ fn equals_except_shell_ignores_shell() { shell_path: "/bin/zsh".into(), shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }, - None, - None, - None, - None, + /*current_date*/ None, + /*timezone*/ None, + /*network*/ None, + /*subagents*/ None, ); assert!(context1.equals_except_shell(&context2)); @@ -252,7 +252,7 @@ fn serialize_environment_context_with_subagents() { fake_shell(), Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), - None, + /*network*/ None, Some("- agent-1: atlas\n- agent-2".to_string()), ); diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index 65c2e7d97d..a06111fd9a 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -56,7 +56,7 @@ fn parses_user_message_with_text_and_two_images() { #[test] fn skips_local_image_label_text() { let image_url = "data:image/png;base64,abc".to_string(); - let label = codex_protocol::models::local_image_open_tag_text(1); + let label = codex_protocol::models::local_image_open_tag_text(/*label_number*/ 1); let user_text = "Please review this image.".to_string(); let item = ResponseItem::Message { diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index a4399ddc46..8a5572a36e 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -17,26 +17,27 @@ use tokio::io::BufReader; use tokio::process::Child; use tokio_util::sync::CancellationToken; -use crate::error::CodexErr; -use crate::error::Result; -use crate::error::SandboxErr; -use crate::protocol::Event; -use crate::protocol::EventMsg; -use crate::protocol::ExecCommandOutputDeltaEvent; -use crate::protocol::ExecOutputStream; -use crate::protocol::SandboxPolicy; use crate::sandboxing::ExecOptions; use crate::sandboxing::ExecRequest; use crate::sandboxing::SandboxPermissions; use crate::spawn::SpawnChildRequest; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; -use crate::text_encoding::bytes_to_string_smart; use codex_network_proxy::NetworkProxy; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::error::CodexErr; +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::permissions::FileSystemSandboxKind; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecCommandOutputDeltaEvent; +use codex_protocol::protocol::ExecOutputStream; +use codex_protocol::protocol::SandboxPolicy; use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; @@ -272,7 +273,7 @@ pub fn build_exec_request( let manager = SandboxManager::new(); let command = SandboxCommand { - program: program.clone(), + program: program.clone().into(), args: args.to_vec(), cwd, env, @@ -292,8 +293,6 @@ pub fn build_exec_request( enforce_managed_network, network: network.as_ref(), sandbox_policy_cwd: sandbox_cwd, - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions: None, codex_linux_sandbox_exe: codex_linux_sandbox_exe.as_ref(), use_legacy_landlock, windows_sandbox_level, @@ -316,7 +315,6 @@ pub fn build_exec_request( pub(crate) async fn execute_exec_request( exec_request: ExecRequest, - sandbox_policy: &SandboxPolicy, stdout_stream: Option, after_spawn: Option>, ) -> Result { @@ -330,13 +328,12 @@ pub(crate) async fn execute_exec_request( sandbox, windows_sandbox_level, windows_sandbox_private_desktop, - sandbox_policy: _sandbox_policy_from_env, + sandbox_policy, file_system_sandbox_policy, network_sandbox_policy, windows_restricted_token_filesystem_overlay, arg0, } = exec_request; - let _ = _sandbox_policy_from_env; let params = ExecParams { command, @@ -356,7 +353,7 @@ pub(crate) async fn execute_exec_request( let raw_output_result = exec( params, sandbox, - sandbox_policy, + &sandbox_policy, &file_system_sandbox_policy, windows_restricted_token_filesystem_overlay.as_ref(), network_sandbox_policy, @@ -420,7 +417,7 @@ fn record_windows_sandbox_spawn_failure( } else { "legacy" }; - if let Some(metrics) = codex_otel::metrics::global() { + if let Some(metrics) = codex_otel::global() { let _ = metrics.counter( "codex.windows_sandbox.createprocessasuserw_failed", /*inc*/ 1, @@ -480,7 +477,11 @@ async fn exec_windows_sandbox( })?; let command_path = command.first().cloned(); let sandbox_level = windows_sandbox_level; - let use_elevated = matches!(sandbox_level, WindowsSandboxLevel::Elevated); + let proxy_enforced = network.is_some(); + // Windows firewall enforcement is tied to the logon-user sandbox identities, so + // proxy-enforced sessions must use that backend even when the configured mode is + // the default restricted-token sandbox. + let use_elevated = proxy_enforced || matches!(sandbox_level, WindowsSandboxLevel::Elevated); let additional_deny_write_paths = windows_restricted_token_filesystem_overlay .map(|overlay| { overlay @@ -493,14 +494,17 @@ async fn exec_windows_sandbox( let spawn_res = tokio::task::spawn_blocking(move || { if use_elevated { run_windows_sandbox_capture_elevated( - policy_str.as_str(), - &sandbox_cwd, - codex_home.as_ref(), - command, - &cwd, - env, - timeout_ms, - windows_sandbox_private_desktop, + codex_windows_sandbox::ElevatedSandboxCaptureRequest { + policy_json_or_preset: policy_str.as_str(), + sandbox_policy_cwd: &sandbox_cwd, + codex_home: codex_home.as_ref(), + command, + cwd: &cwd, + env_map: env, + timeout_ms, + use_private_desktop: windows_sandbox_private_desktop, + proxy_enforced, + }, ) } else { run_windows_sandbox_capture_with_extra_deny_write_paths( @@ -629,25 +633,6 @@ fn finalize_exec_result( } } -pub(crate) mod errors { - use super::CodexErr; - use codex_sandboxing::SandboxTransformError; - - impl From for CodexErr { - fn from(err: SandboxTransformError) -> Self { - match err { - SandboxTransformError::MissingLinuxSandboxExecutable => { - CodexErr::LandlockSandboxExecutableNotProvided - } - #[cfg(not(target_os = "macos"))] - SandboxTransformError::SeatbeltUnavailable => CodexErr::UnsupportedOperation( - "seatbelt sandbox is only available on macOS".to_string(), - ), - } - } - } -} - /// We don't have a fully deterministic way to tell if our command failed /// because of the sandbox - a command in the user's zshrc file might hit an /// error, but the command itself might fail or succeed for other reasons. @@ -710,12 +695,6 @@ pub(crate) fn is_likely_sandbox_denied( false } -#[derive(Debug, Clone)] -pub struct StreamOutput { - pub text: T, - pub truncated_after_lines: Option, -} - #[derive(Debug)] struct RawExecToolCallOutput { pub exit_status: ExitStatus, @@ -725,24 +704,6 @@ struct RawExecToolCallOutput { pub timed_out: bool, } -impl StreamOutput { - pub fn new(text: String) -> Self { - Self { - text, - truncated_after_lines: None, - } - } -} - -impl StreamOutput> { - pub fn from_utf8_lossy(&self) -> StreamOutput { - StreamOutput { - text: bytes_to_string_smart(&self.text), - truncated_after_lines: self.truncated_after_lines, - } - } -} - #[inline] fn append_capped(dst: &mut Vec, src: &[u8], max_bytes: usize) { if dst.len() >= max_bytes { @@ -797,29 +758,6 @@ fn aggregate_output( } } -#[derive(Clone, Debug)] -pub struct ExecToolCallOutput { - pub exit_code: i32, - pub stdout: StreamOutput, - pub stderr: StreamOutput, - pub aggregated_output: StreamOutput, - pub duration: Duration, - pub timed_out: bool, -} - -impl Default for ExecToolCallOutput { - fn default() -> Self { - Self { - exit_code: 0, - stdout: StreamOutput::new(String::new()), - stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new(String::new()), - duration: Duration::ZERO, - timed_out: false, - } - } -} - #[allow(clippy::too_many_arguments)] async fn exec( params: ExecParams, diff --git a/codex-rs/core/src/exec_env.rs b/codex-rs/core/src/exec_env.rs index 83ac8ad379..a50fcc2538 100644 --- a/codex-rs/core/src/exec_env.rs +++ b/codex-rs/core/src/exec_env.rs @@ -1,6 +1,6 @@ -use crate::config::types::EnvironmentVariablePattern; -use crate::config::types::ShellEnvironmentPolicy; -use crate::config::types::ShellEnvironmentPolicyInherit; +use codex_config::types::EnvironmentVariablePattern; +use codex_config::types::ShellEnvironmentPolicy; +use codex_config::types::ShellEnvironmentPolicyInherit; use codex_protocol::ThreadId; use std::collections::HashMap; use std::collections::HashSet; @@ -21,9 +21,46 @@ pub fn create_env( policy: &ShellEnvironmentPolicy, thread_id: Option, ) -> HashMap { - populate_env(std::env::vars(), policy, thread_id) + create_env_from_vars(std::env::vars(), policy, thread_id) } +fn create_env_from_vars( + vars: I, + policy: &ShellEnvironmentPolicy, + thread_id: Option, +) -> HashMap +where + I: IntoIterator, +{ + let mut env_map = populate_env(vars, policy, thread_id); + + if cfg!(target_os = "windows") { + // This is a workaround to address the failures we are seeing in the + // following tests when run via Bazel on Windows: + // + // ``` + // suite::shell_command::unicode_output::with_login + // suite::shell_command::unicode_output::without_login + // ``` + // + // Currently, we can only reproduce these failures in CI, which makes + // iteration times long, so we include this quick fix for now to unblock + // getting the Windows Bazel build running. + if !env_map.keys().any(|k| k.eq_ignore_ascii_case("PATHEXT")) { + env_map.insert("PATHEXT".to_string(), ".COM;.EXE;.BAT;.CMD".to_string()); + } + } + env_map +} + +const COMMON_CORE_VARS: &[&str] = &["PATH", "SHELL", "TMPDIR", "TEMP", "TMP"]; + +#[cfg(target_os = "windows")] +const PLATFORM_CORE_VARS: &[&str] = &["PATHEXT", "USERNAME", "USERPROFILE"]; + +#[cfg(unix)] +const PLATFORM_CORE_VARS: &[&str] = &["HOME", "LANG", "LC_ALL", "LC_CTYPE", "LOGNAME", "USER"]; + fn populate_env( vars: I, policy: &ShellEnvironmentPolicy, @@ -38,17 +75,18 @@ where ShellEnvironmentPolicyInherit::All => vars.into_iter().collect(), ShellEnvironmentPolicyInherit::None => HashMap::new(), ShellEnvironmentPolicyInherit::Core => { - const CORE_VARS: &[&str] = &[ - "HOME", "LOGNAME", "PATH", "SHELL", "USER", "USERNAME", "TMPDIR", "TEMP", "TMP", - ]; - let allow: HashSet<&str> = CORE_VARS.iter().copied().collect(); + let core_vars: HashSet<&str> = COMMON_CORE_VARS + .iter() + .copied() + .chain(PLATFORM_CORE_VARS.iter().copied()) + .collect(); let is_core_var = |name: &str| { if cfg!(target_os = "windows") { - CORE_VARS + core_vars .iter() .any(|allowed| allowed.eq_ignore_ascii_case(name)) } else { - allow.contains(name) + core_vars.contains(name) } }; vars.into_iter().filter(|(k, _)| is_core_var(k)).collect() diff --git a/codex-rs/core/src/exec_env_tests.rs b/codex-rs/core/src/exec_env_tests.rs index 6f001b5828..81b5c0bb30 100644 --- a/codex-rs/core/src/exec_env_tests.rs +++ b/codex-rs/core/src/exec_env_tests.rs @@ -1,6 +1,7 @@ use super::*; -use crate::config::types::ShellEnvironmentPolicyInherit; +use codex_config::types::ShellEnvironmentPolicyInherit; use maplit::hashmap; +use pretty_assertions::assert_eq; fn make_vars(pairs: &[(&str, &str)]) -> Vec<(String, String)> { pairs @@ -121,7 +122,7 @@ fn populate_env_inserts_thread_id() { fn populate_env_omits_thread_id_when_missing() { let vars = make_vars(&[("PATH", "/usr/bin")]); let policy = ShellEnvironmentPolicy::default(); - let result = populate_env(vars, &policy, None); + let result = populate_env(vars, &policy, /*thread_id*/ None); let expected: HashMap = hashmap! { "PATH".to_string() => "/usr/bin".to_string(), @@ -171,6 +172,7 @@ fn test_inherit_all_with_default_excludes() { fn test_core_inherit_respects_case_insensitive_names_on_windows() { let vars = make_vars(&[ ("Path", "C:\\Windows\\System32"), + ("PathExt", ".COM;.EXE;.BAT;.CMD"), ("TEMP", "C:\\Temp"), ("FOO", "bar"), ]); @@ -185,6 +187,7 @@ fn test_core_inherit_respects_case_insensitive_names_on_windows() { let result = populate_env(vars, &policy, Some(thread_id)); let mut expected: HashMap = hashmap! { "Path".to_string() => "C:\\Windows\\System32".to_string(), + "PathExt".to_string() => ".COM;.EXE;.BAT;.CMD".to_string(), "TEMP".to_string() => "C:\\Temp".to_string(), }; expected.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string()); @@ -192,6 +195,47 @@ fn test_core_inherit_respects_case_insensitive_names_on_windows() { assert_eq!(result, expected); } +#[test] +#[cfg(target_os = "windows")] +fn create_env_inserts_pathext_on_windows_when_missing() { + let vars = make_vars(&[]); + + let policy = ShellEnvironmentPolicy { + inherit: ShellEnvironmentPolicyInherit::None, + ignore_default_excludes: true, + ..Default::default() + }; + + let result = create_env_from_vars(vars, &policy, /*thread_id*/ None); + + let expected: HashMap = hashmap! { + "PATHEXT".to_string() => ".COM;.EXE;.BAT;.CMD".to_string(), + }; + assert_eq!(result, expected); +} + +#[test] +#[cfg(target_os = "windows")] +fn create_env_preserves_existing_pathext_case_insensitively_on_windows() { + let vars = make_vars(&[("PathExt", ".COM;.EXE;.BAT;.CMD;.PS1")]); + + let policy = ShellEnvironmentPolicy { + inherit: ShellEnvironmentPolicyInherit::Core, + ignore_default_excludes: true, + ..Default::default() + }; + + let result = create_env_from_vars(vars, &policy, /*thread_id*/ None); + + let pathext_vars = result + .iter() + .filter(|(key, _)| key.eq_ignore_ascii_case("PATHEXT")) + .collect::>(); + + assert_eq!(pathext_vars.len(), 1); + assert_eq!(pathext_vars[0].1, ".COM;.EXE;.BAT;.CMD;.PS1"); +} + #[test] fn test_inherit_none() { let vars = make_vars(&[("PATH", "/usr/bin"), ("HOME", "/home")]); diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index a027f2461b..98bb815e91 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -7,8 +7,6 @@ use arc_swap::ArcSwap; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigLayerStackOrdering; -use crate::is_dangerous_command::command_might_be_dangerous; -use crate::is_safe_command::is_known_safe_command; use codex_execpolicy::AmendError; use codex_execpolicy::Decision; use codex_execpolicy::Error as ExecPolicyRuleError; @@ -25,16 +23,18 @@ 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; use tokio::fs; use tokio::task::spawn_blocking; use tracing::instrument; -use crate::bash::parse_shell_lc_plain_commands; -use crate::bash::parse_shell_lc_single_command_prefix; use crate::config::Config; use crate::sandboxing::SandboxPermissions; use crate::tools::sandboxing::ExecApprovalRequirement; +use codex_shell_command::bash::parse_shell_lc_plain_commands; +use codex_shell_command::bash::parse_shell_lc_single_command_prefix; use codex_utils_absolute_path::AbsolutePathBuf; use shlex::try_join as shlex_try_join; @@ -494,8 +494,7 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result FileSystemSandboxPolicy { async fn test_config() -> (TempDir, Config) { let home = TempDir::new().expect("create temp dir"); - let config = ConfigBuilder::default() + let config = ConfigBuilder::without_managed_config_for_tests() .codex_home(home.path().to_path_buf()) - .loader_overrides(LoaderOverrides { - #[cfg(target_os = "macos")] - managed_preferences_base64: Some(String::new()), - macos_managed_config_requirements_base64: Some(String::new()), - ..LoaderOverrides::default() - }) .build() .await .expect("load default test config"); @@ -115,7 +108,10 @@ async fn child_uses_parent_exec_policy_when_non_exec_policy_layers_differ() { let mut child_config = parent_config.clone(); let mut layers: Vec<_> = child_config .config_layer_stack - .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true) + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) .into_iter() .cloned() .collect(); @@ -156,7 +152,10 @@ async fn child_does_not_use_parent_exec_policy_when_requirements_exec_policy_dif child_config.config_layer_stack = ConfigLayerStack::new( child_config .config_layer_stack - .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true) + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) .into_iter() .cloned() .collect(), @@ -291,7 +290,7 @@ async fn merges_requirements_exec_policy_network_rules() -> anyhow::Result<()> { "blocked.example.com", codex_execpolicy::NetworkRuleProtocol::Https, Decision::Forbidden, - None, + /*justification*/ None, )?; let requirements = ConfigRequirements { @@ -338,7 +337,7 @@ host_executable(name = "git", paths = ["{git_path_literal}"]) "blocked.example.com", codex_execpolicy::NetworkRuleProtocol::Https, Decision::Forbidden, - None, + /*justification*/ None, )?; let requirements = ConfigRequirements { @@ -805,7 +804,7 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() { &read_only_file_system_sandbox_policy(), &command, SandboxPermissions::RequireEscalated, - false, + /*used_complex_parsing*/ false, ) ); } @@ -823,11 +822,38 @@ fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() { &restricted_file_system_policy, &command, SandboxPermissions::RequireEscalated, - false, + /*used_complex_parsing*/ false, ) ); } +#[tokio::test] +async fn exec_approval_requirement_prompts_for_inline_additional_permissions_under_on_request() { + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: vec![ + "zsh".to_string(), + "-lc".to_string(), + "touch requested-dir/requested-but-unused.txt".to_string(), + ], + approval_policy: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, + prefix_rule: None, + }, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "touch".to_string(), + "requested-dir/requested-but-unused.txt".to_string(), + ])), + }, + ) + .await; +} + #[tokio::test] async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_granular_sandbox_is_disabled() { @@ -1321,7 +1347,7 @@ fn derive_requested_execpolicy_amendment_for_test( fn derive_requested_execpolicy_amendment_returns_none_for_missing_prefix_rule() { assert_eq!( None, - derive_requested_execpolicy_amendment_for_test(None, &[]) + derive_requested_execpolicy_amendment_for_test(/*prefix_rule*/ None, &[]) ); } diff --git a/codex-rs/core/src/exec_tests.rs b/codex-rs/core/src/exec_tests.rs index 074db95e1c..cf00d70501 100644 --- a/codex-rs/core/src/exec_tests.rs +++ b/codex-rs/core/src/exec_tests.rs @@ -24,7 +24,7 @@ fn make_exec_output( #[test] fn sandbox_detection_requires_keywords() { - let output = make_exec_output(1, "", "", ""); + let output = make_exec_output(/*exit_code*/ 1, "", "", ""); assert!(!is_likely_sandbox_denied( SandboxType::LinuxSeccomp, &output @@ -33,13 +33,13 @@ fn sandbox_detection_requires_keywords() { #[test] fn sandbox_detection_identifies_keyword_in_stderr() { - let output = make_exec_output(1, "", "Operation not permitted", ""); + let output = make_exec_output(/*exit_code*/ 1, "", "Operation not permitted", ""); assert!(is_likely_sandbox_denied(SandboxType::LinuxSeccomp, &output)); } #[test] fn sandbox_detection_respects_quick_reject_exit_codes() { - let output = make_exec_output(127, "", "command not found", ""); + let output = make_exec_output(/*exit_code*/ 127, "", "command not found", ""); assert!(!is_likely_sandbox_denied( SandboxType::LinuxSeccomp, &output @@ -48,14 +48,14 @@ fn sandbox_detection_respects_quick_reject_exit_codes() { #[test] fn sandbox_detection_ignores_non_sandbox_mode() { - let output = make_exec_output(1, "", "Operation not permitted", ""); + let output = make_exec_output(/*exit_code*/ 1, "", "Operation not permitted", ""); assert!(!is_likely_sandbox_denied(SandboxType::None, &output)); } #[test] fn sandbox_detection_ignores_network_policy_text_in_non_sandbox_mode() { let output = make_exec_output( - 0, + /*exit_code*/ 0, "", "", r#"CODEX_NETWORK_POLICY_DECISION {"decision":"ask","reason":"not_allowed","source":"decider","protocol":"http","host":"google.com","port":80}"#, @@ -66,7 +66,7 @@ fn sandbox_detection_ignores_network_policy_text_in_non_sandbox_mode() { #[test] fn sandbox_detection_uses_aggregated_output() { let output = make_exec_output( - 101, + /*exit_code*/ 101, "", "", "cargo failed: Read-only file system when writing target", @@ -80,7 +80,7 @@ fn sandbox_detection_uses_aggregated_output() { #[test] fn sandbox_detection_ignores_network_policy_text_with_zero_exit_code() { let output = make_exec_output( - 0, + /*exit_code*/ 0, "", "", r#"CODEX_NETWORK_POLICY_DECISION {"decision":"ask","source":"decider","protocol":"http","host":"google.com","port":80}"#, @@ -100,9 +100,14 @@ async fn read_output_limits_retained_bytes_for_shell_capture() { writer.write_all(&bytes).await.expect("write"); }); - let out = read_output(reader, None, false, Some(EXEC_OUTPUT_MAX_BYTES)) - .await - .expect("read"); + let out = read_output( + reader, + /*stream*/ None, + /*is_stderr*/ false, + Some(EXEC_OUTPUT_MAX_BYTES), + ) + .await + .expect("read"); assert_eq!(out.text.len(), EXEC_OUTPUT_MAX_BYTES); } @@ -196,7 +201,11 @@ async fn read_output_retains_all_bytes_for_full_buffer_capture() { writer.write_all(&bytes).await.expect("write"); }); - let out = read_output(reader, None, false, None).await.expect("read"); + let out = read_output( + reader, /*stream*/ None, /*is_stderr*/ false, /*max_bytes*/ None, + ) + .await + .expect("read"); assert_eq!(out.text.len(), expected_len); } @@ -211,7 +220,7 @@ fn aggregate_output_keeps_all_bytes_when_uncapped() { truncated_after_lines: None, }; - let aggregated = aggregate_output(&stdout, &stderr, None); + let aggregated = aggregate_output(&stdout, &stderr, /*max_bytes*/ None); assert_eq!(aggregated.text.len(), EXEC_OUTPUT_MAX_BYTES * 2); assert_eq!( @@ -362,8 +371,8 @@ async fn process_exec_tool_call_preserves_full_buffer_capture_policy() -> Result NetworkSandboxPolicy::Enabled, cwd.as_path(), &None, - false, - None, + /*use_legacy_landlock*/ false, + /*stdout_stream*/ None, ) .await?; @@ -595,6 +604,19 @@ fn windows_restricted_token_supports_full_read_split_write_read_carveouts() { }, ]); + #[cfg(windows)] + let expected_deny_write_paths = vec![ + codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(cwd.join(".codex")) + .expect("absolute .codex"), + codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + ]; + #[cfg(not(windows))] + let expected_deny_write_paths = vec![ + codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + ]; + assert_eq!( resolve_windows_restricted_token_filesystem_overlay( SandboxType::WindowsRestrictedToken, @@ -605,10 +627,7 @@ fn windows_restricted_token_supports_full_read_split_write_read_carveouts() { WindowsSandboxLevel::RestrictedToken, ), Ok(Some(WindowsRestrictedTokenFilesystemOverlay { - additional_deny_write_paths: vec![ - codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) - .expect("absolute docs"), - ], + additional_deny_write_paths: expected_deny_write_paths, })) ); } @@ -665,14 +684,15 @@ fn windows_elevated_rejects_split_write_read_carveouts() { #[test] fn process_exec_tool_call_uses_platform_sandbox_for_network_only_restrictions() { - let expected = codex_sandboxing::get_platform_sandbox(false).unwrap_or(SandboxType::None); + let expected = codex_sandboxing::get_platform_sandbox(/*windows_sandbox_enabled*/ false) + .unwrap_or(SandboxType::None); assert_eq!( select_process_exec_tool_sandbox_type( &FileSystemSandboxPolicy::unrestricted(), NetworkSandboxPolicy::Restricted, codex_protocol::config_types::WindowsSandboxLevel::Disabled, - false, + /*enforce_managed_network*/ false, ), expected ); @@ -726,8 +746,8 @@ async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()> &FileSystemSandboxPolicy::from(&SandboxPolicy::new_read_only_policy()), None, NetworkSandboxPolicy::Restricted, - None, - None, + /*stdout_stream*/ None, + /*after_spawn*/ None, ) .await?; assert!(output.timed_out); @@ -788,8 +808,8 @@ async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> { NetworkSandboxPolicy::Enabled, cwd.as_path(), &None, - false, - None, + /*use_legacy_landlock*/ false, + /*stdout_stream*/ None, ) .await; let output = match result { diff --git a/codex-rs/core/src/external_agent_config.rs b/codex-rs/core/src/external_agent_config.rs index 6011d18ee4..efa90c8494 100644 --- a/codex-rs/core/src/external_agent_config.rs +++ b/codex-rs/core/src/external_agent_config.rs @@ -676,7 +676,7 @@ fn emit_migration_metric( item_type: ExternalAgentConfigMigrationItemType, skills_count: Option, ) { - let Some(metrics) = codex_otel::metrics::global() else { + let Some(metrics) = codex_otel::global() else { return; }; let tags = migration_metric_tags(item_type, skills_count); diff --git a/codex-rs/core/src/external_agent_config_tests.rs b/codex-rs/core/src/external_agent_config_tests.rs index a760f73e19..7baadbec01 100644 --- a/codex-rs/core/src/external_agent_config_tests.rs +++ b/codex-rs/core/src/external_agent_config_tests.rs @@ -390,7 +390,7 @@ fn import_skills_returns_only_new_skill_directory_count() { fs::create_dir_all(agents_skills.join("skill-a")).expect("create existing target"); let copied_count = service_for_paths(claude_home, codex_home) - .import_skills(None) + .import_skills(/*cwd*/ None) .expect("import skills"); assert_eq!(copied_count, 1); diff --git a/codex-rs/core/src/file_watcher_tests.rs b/codex-rs/core/src/file_watcher_tests.rs index 53f6391f57..11bb37c8c6 100644 --- a/codex-rs/core/src/file_watcher_tests.rs +++ b/codex-rs/core/src/file_watcher_tests.rs @@ -115,10 +115,10 @@ fn is_mutating_event_filters_non_mutating_event_kinds() { fn register_dedupes_by_path_and_scope() { let watcher = Arc::new(FileWatcher::noop()); let (subscriber, _rx) = watcher.add_subscriber(); - let _first = subscriber.register_path(path("/tmp/skills"), false); - let _second = subscriber.register_path(path("/tmp/skills"), false); - let _third = subscriber.register_path(path("/tmp/skills"), true); - let _fourth = subscriber.register_path(path("/tmp/other-skills"), true); + let _first = subscriber.register_path(path("/tmp/skills"), /*recursive*/ false); + let _second = subscriber.register_path(path("/tmp/skills"), /*recursive*/ false); + let _third = subscriber.register_path(path("/tmp/skills"), /*recursive*/ true); + let _fourth = subscriber.register_path(path("/tmp/other-skills"), /*recursive*/ true); assert_eq!( watcher.watch_counts_for_test(&path("/tmp/skills")), @@ -134,7 +134,7 @@ fn register_dedupes_by_path_and_scope() { fn watch_registration_drop_unregisters_paths() { let watcher = Arc::new(FileWatcher::noop()); let (subscriber, _rx) = watcher.add_subscriber(); - let registration = subscriber.register_path(path("/tmp/skills"), true); + let registration = subscriber.register_path(path("/tmp/skills"), /*recursive*/ true); drop(registration); @@ -146,7 +146,7 @@ fn subscriber_drop_unregisters_paths() { let watcher = Arc::new(FileWatcher::noop()); let registration = { let (subscriber, _rx) = watcher.add_subscriber(); - subscriber.register_path(path("/tmp/skills"), true) + subscriber.register_path(path("/tmp/skills"), /*recursive*/ true) }; assert_eq!(watcher.watch_counts_for_test(&path("/tmp/skills")), None); @@ -174,8 +174,8 @@ fn recursive_registration_downgrades_to_non_recursive_after_drop() { let watcher = Arc::new(FileWatcher::new().expect("watcher")); let (subscriber, _rx) = watcher.add_subscriber(); - let non_recursive = subscriber.register_path(root.clone(), false); - let recursive = subscriber.register_path(root.clone(), true); + let non_recursive = subscriber.register_path(root.clone(), /*recursive*/ false); + let recursive = subscriber.register_path(root.clone(), /*recursive*/ true); { let inner = watcher.inner.as_ref().expect("watcher inner"); @@ -209,7 +209,7 @@ fn unregister_holds_state_lock_until_unwatch_finishes() { let watcher = Arc::new(FileWatcher::new().expect("watcher")); let (unregister_subscriber, _unregister_rx) = watcher.add_subscriber(); let (register_subscriber, _register_rx) = watcher.add_subscriber(); - let registration = unregister_subscriber.register_path(root.clone(), true); + let registration = unregister_subscriber.register_path(root.clone(), /*recursive*/ true); let inner = watcher.inner.as_ref().expect("watcher inner"); let inner_guard = inner.lock().expect("inner lock"); @@ -229,7 +229,8 @@ fn unregister_holds_state_lock_until_unwatch_finishes() { let register_root = root.clone(); let register_thread = std::thread::spawn(move || { - let registration = register_subscriber.register_path(register_root, false); + let registration = + register_subscriber.register_path(register_root, /*recursive*/ false); (register_subscriber, registration) }); @@ -257,8 +258,8 @@ async fn matching_subscribers_are_notified() { let watcher = Arc::new(FileWatcher::noop()); let (skills_subscriber, skills_rx) = watcher.add_subscriber(); let (plugins_subscriber, plugins_rx) = watcher.add_subscriber(); - let _skills = skills_subscriber.register_path(path("/tmp/skills"), true); - let _plugins = plugins_subscriber.register_path(path("/tmp/plugins"), true); + let _skills = skills_subscriber.register_path(path("/tmp/skills"), /*recursive*/ true); + let _plugins = plugins_subscriber.register_path(path("/tmp/plugins"), /*recursive*/ true); let mut skills_rx = ThrottledWatchReceiver::new(skills_rx, TEST_THROTTLE_INTERVAL); let mut plugins_rx = ThrottledWatchReceiver::new(plugins_rx, TEST_THROTTLE_INTERVAL); @@ -285,7 +286,7 @@ async fn matching_subscribers_are_notified() { async fn non_recursive_watch_ignores_grandchildren() { let watcher = Arc::new(FileWatcher::noop()); let (subscriber, rx) = watcher.add_subscriber(); - let _registration = subscriber.register_path(path("/tmp/skills"), false); + let _registration = subscriber.register_path(path("/tmp/skills"), /*recursive*/ false); let mut rx = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL); watcher @@ -300,7 +301,8 @@ async fn non_recursive_watch_ignores_grandchildren() { async fn ancestor_events_notify_child_watches() { let watcher = Arc::new(FileWatcher::noop()); let (subscriber, rx) = watcher.add_subscriber(); - let _registration = subscriber.register_path(path("/tmp/skills/rust/SKILL.md"), false); + let _registration = + subscriber.register_path(path("/tmp/skills/rust/SKILL.md"), /*recursive*/ false); let mut rx = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL); watcher.send_paths_for_test(vec![path("/tmp/skills")]).await; @@ -321,7 +323,7 @@ async fn ancestor_events_notify_child_watches() { async fn spawn_event_loop_filters_non_mutating_events() { let watcher = Arc::new(FileWatcher::noop()); let (subscriber, rx) = watcher.add_subscriber(); - let _registration = subscriber.register_path(path("/tmp/skills"), true); + let _registration = subscriber.register_path(path("/tmp/skills"), /*recursive*/ true); let mut rx = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL); let (raw_tx, raw_rx) = mpsc::unbounded_channel(); watcher.spawn_event_loop_for_test(raw_rx); diff --git a/codex-rs/core/src/git_info_tests.rs b/codex-rs/core/src/git_info_tests.rs index 90ceb7241e..86092c78f0 100644 --- a/codex-rs/core/src/git_info_tests.rs +++ b/codex-rs/core/src/git_info_tests.rs @@ -72,7 +72,7 @@ async fn create_test_git_repo(temp_dir: &TempDir) -> PathBuf { #[tokio::test] async fn test_recent_commits_non_git_directory_returns_empty() { let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let entries = recent_commits(temp_dir.path(), 10).await; + let entries = recent_commits(temp_dir.path(), /*limit*/ 10).await; assert!(entries.is_empty(), "expected no commits outside a git repo"); } @@ -133,7 +133,7 @@ async fn test_recent_commits_orders_and_limits() { .expect("git commit 3"); // Request the latest 3 commits; should be our three changes in reverse time order. - let entries = recent_commits(&repo_path, 3).await; + let entries = recent_commits(&repo_path, /*limit*/ 3).await; assert_eq!(entries.len(), 3); assert_eq!(entries[0].subject, "third change"); assert_eq!(entries[1].subject, "second change"); diff --git a/codex-rs/core/src/guardian/approval_request.rs b/codex-rs/core/src/guardian/approval_request.rs index 533c9ee11e..b89d020ca8 100644 --- a/codex-rs/core/src/guardian/approval_request.rs +++ b/codex-rs/core/src/guardian/approval_request.rs @@ -1,5 +1,8 @@ +use std::path::Path; use std::path::PathBuf; +use codex_protocol::approvals::GuardianAssessmentAction; +use codex_protocol::approvals::GuardianCommandSource; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; @@ -31,7 +34,7 @@ pub(crate) enum GuardianApprovalRequest { #[cfg(unix)] Execve { id: String, - tool_name: String, + source: GuardianCommandSource, program: String, argv: Vec, cwd: PathBuf, @@ -41,7 +44,6 @@ pub(crate) enum GuardianApprovalRequest { id: String, cwd: PathBuf, files: Vec, - change_count: usize, patch: String, }, NetworkAccess { @@ -80,7 +82,7 @@ pub(crate) struct GuardianMcpAnnotations { struct CommandApprovalAction<'a> { tool: &'a str, command: &'a [String], - cwd: &'a PathBuf, + cwd: &'a Path, sandbox_permissions: crate::sandboxing::SandboxPermissions, #[serde(skip_serializing_if = "Option::is_none")] additional_permissions: Option<&'a PermissionProfile>, @@ -96,7 +98,7 @@ struct ExecveApprovalAction<'a> { tool: &'a str, program: &'a str, argv: &'a [String], - cwd: &'a PathBuf, + cwd: &'a Path, #[serde(skip_serializing_if = "Option::is_none")] additional_permissions: Option<&'a PermissionProfile>, } @@ -129,7 +131,7 @@ fn serialize_guardian_action(value: impl Serialize) -> serde_json::Result fn serialize_command_guardian_action( tool: &'static str, command: &[String], - cwd: &PathBuf, + cwd: &Path, sandbox_permissions: crate::sandboxing::SandboxPermissions, additional_permissions: Option<&PermissionProfile>, justification: Option<&String>, @@ -146,12 +148,24 @@ fn serialize_command_guardian_action( }) } -fn command_assessment_action_value(tool: &'static str, command: &[String], cwd: &PathBuf) -> Value { - serde_json::json!({ - "tool": tool, - "command": codex_shell_command::parse_command::shlex_join(command), - "cwd": cwd, - }) +fn command_assessment_action( + source: GuardianCommandSource, + command: &[String], + cwd: &Path, +) -> GuardianAssessmentAction { + GuardianAssessmentAction::Command { + source, + command: codex_shell_command::parse_command::shlex_join(command), + cwd: cwd.to_path_buf(), + } +} + +#[cfg(unix)] +fn guardian_command_source_tool_name(source: GuardianCommandSource) -> &'static str { + match source { + GuardianCommandSource::Shell => "shell", + GuardianCommandSource::UnifiedExec => "exec_command", + } } fn truncate_guardian_action_value(value: Value) -> Value { @@ -220,13 +234,13 @@ pub(crate) fn guardian_approval_request_to_json( #[cfg(unix)] GuardianApprovalRequest::Execve { id: _, - tool_name, + source, program, argv, cwd, additional_permissions, } => serialize_guardian_action(ExecveApprovalAction { - tool: tool_name, + tool: guardian_command_source_tool_name(*source), program, argv, cwd, @@ -236,13 +250,11 @@ pub(crate) fn guardian_approval_request_to_json( id: _, cwd, files, - change_count, patch, } => Ok(serde_json::json!({ "tool": "apply_patch", "cwd": cwd, "files": files, - "change_count": change_count, "patch": patch, })), GuardianApprovalRequest::NetworkAccess { @@ -285,38 +297,38 @@ pub(crate) fn guardian_approval_request_to_json( } } -pub(crate) fn guardian_assessment_action_value(action: &GuardianApprovalRequest) -> Value { +pub(crate) fn guardian_assessment_action( + action: &GuardianApprovalRequest, +) -> GuardianAssessmentAction { match action { GuardianApprovalRequest::Shell { command, cwd, .. } => { - command_assessment_action_value("shell", command, cwd) + command_assessment_action(GuardianCommandSource::Shell, command, cwd) } GuardianApprovalRequest::ExecCommand { command, cwd, .. } => { - command_assessment_action_value("exec_command", command, cwd) + command_assessment_action(GuardianCommandSource::UnifiedExec, command, cwd) } #[cfg(unix)] GuardianApprovalRequest::Execve { - tool_name, + source, program, argv, cwd, .. - } => serde_json::json!({ - "tool": tool_name, - "program": program, - "argv": argv, - "cwd": cwd, - }), - GuardianApprovalRequest::ApplyPatch { - cwd, - files, - change_count, - .. - } => serde_json::json!({ - "tool": "apply_patch", - "cwd": cwd, - "files": files, - "change_count": change_count, - }), + } => GuardianAssessmentAction::Execve { + source: *source, + program: program.clone(), + argv: argv.clone(), + cwd: cwd.clone(), + }, + GuardianApprovalRequest::ApplyPatch { cwd, files, .. } => { + GuardianAssessmentAction::ApplyPatch { + cwd: cwd.clone(), + files: files + .iter() + .map(codex_utils_absolute_path::AbsolutePathBuf::to_path_buf) + .collect(), + } + } GuardianApprovalRequest::NetworkAccess { id: _, turn_id: _, @@ -324,20 +336,26 @@ pub(crate) fn guardian_assessment_action_value(action: &GuardianApprovalRequest) host, protocol, port, - } => serde_json::json!({ - "tool": "network_access", - "target": target, - "host": host, - "protocol": protocol, - "port": port, - }), + } => GuardianAssessmentAction::NetworkAccess { + target: target.clone(), + host: host.clone(), + protocol: *protocol, + port: *port, + }, GuardianApprovalRequest::McpToolCall { - server, tool_name, .. - } => serde_json::json!({ - "tool": "mcp_tool_call", - "server": server, - "tool_name": tool_name, - }), + server, + tool_name, + connector_id, + connector_name, + tool_title, + .. + } => GuardianAssessmentAction::McpToolCall { + server: server.clone(), + tool_name: tool_name.clone(), + connector_id: connector_id.clone(), + connector_name: connector_name.clone(), + tool_title: tool_title.clone(), + }, } } diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index 8fd0e994a9..1a7add9d38 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -38,7 +38,7 @@ const GUARDIAN_MAX_MESSAGE_TRANSCRIPT_TOKENS: usize = 10_000; const GUARDIAN_MAX_TOOL_TRANSCRIPT_TOKENS: usize = 10_000; const GUARDIAN_MAX_MESSAGE_ENTRY_TOKENS: usize = 2_000; const GUARDIAN_MAX_TOOL_ENTRY_TOKENS: usize = 1_000; -const GUARDIAN_MAX_ACTION_STRING_TOKENS: usize = 1_000; +const GUARDIAN_MAX_ACTION_STRING_TOKENS: usize = 16_000; const GUARDIAN_APPROVAL_RISK_THRESHOLD: u8 = 80; const GUARDIAN_RECENT_ENTRY_LIMIT: usize = 40; const TRUNCATION_TAG: &str = "truncated"; @@ -62,7 +62,7 @@ pub(crate) struct GuardianAssessment { #[cfg(test)] use approval_request::format_guardian_action_pretty; #[cfg(test)] -use approval_request::guardian_assessment_action_value; +use approval_request::guardian_assessment_action; #[cfg(test)] use approval_request::guardian_request_turn_id; #[cfg(test)] diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 5741a65fc0..7558c005b3 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -107,16 +107,22 @@ pub(crate) async fn build_guardian_prompt_items( Ok(items) } -/// Keeps all user turns plus a bounded amount of recent assistant/tool context. +/// Renders a compact guardian transcript from the retained history entries, +/// which are only user, assistant, and tool call entries. /// -/// The pruning strategy is intentionally simple and reviewable: -/// - always retain user messages because they carry authorization and intent -/// - walk recent non-user entries from newest to oldest -/// - keep them only while the message/tool budgets allow -/// - reserve a separate tool budget so tool evidence cannot crowd out the human -/// conversation +/// Selection is intentionally simple and predictable: +/// - each entry is truncated to its per-entry cap +/// - user and assistant entries share the message budget +/// - tool calls/results use a separate tool budget so tool evidence cannot +/// crowd out the human conversation +/// - if all user turns fit, keep them all +/// - otherwise keep the first and latest user turns as anchors, then fill the +/// remaining message budget with other user turns from newest to oldest +/// - after user turns are selected, keep recent non-user entries from newest to +/// oldest while the budgets and recent-entry limit allow /// -/// User messages are never dropped unless the entire transcript must be omitted. +/// Returns the rendered transcript plus an omission note when some entries were +/// skipped. pub(crate) fn render_guardian_transcript_entries( entries: &[GuardianTranscriptEntry], ) -> (Vec, Option) { @@ -143,20 +149,38 @@ pub(crate) fn render_guardian_transcript_entries( let mut included = vec![false; entries.len()]; let mut message_tokens = 0usize; let mut tool_tokens = 0usize; + let user_indices = entries + .iter() + .enumerate() + .filter_map(|(index, entry)| entry.kind.is_user().then_some(index)) + .collect::>(); - for (index, entry) in entries.iter().enumerate() { - if !entry.kind.is_user() { + if let Some(&first_user_index) = user_indices.first() { + included[first_user_index] = true; + message_tokens += rendered_entries[first_user_index].1; + } + + if let Some(&last_user_index) = user_indices.last() + && !included[last_user_index] + && message_tokens + rendered_entries[last_user_index].1 + <= GUARDIAN_MAX_MESSAGE_TRANSCRIPT_TOKENS + { + included[last_user_index] = true; + message_tokens += rendered_entries[last_user_index].1; + } + + for &index in user_indices.iter().rev() { + if included[index] { continue; } - message_tokens += rendered_entries[index].1; - if message_tokens > GUARDIAN_MAX_MESSAGE_TRANSCRIPT_TOKENS { - return ( - vec!["".to_string()], - Some("Conversation transcript omitted due to size.".to_string()), - ); + let token_count = rendered_entries[index].1; + if message_tokens + token_count > GUARDIAN_MAX_MESSAGE_TRANSCRIPT_TOKENS { + continue; } + included[index] = true; + message_tokens += token_count; } let mut retained_non_user_entries = 0usize; @@ -192,8 +216,7 @@ pub(crate) fn render_guardian_transcript_entries( .map(|(index, _)| rendered_entries[index].0.clone()) .collect::>(); let omitted_any = included.iter().any(|included_entry| !included_entry); - let omission_note = - omitted_any.then(|| "Earlier conversation entries were omitted.".to_string()); + let omission_note = omitted_any.then(|| "Some conversation entries were omitted.".to_string()); (transcript, omission_note) } diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 3a491f6efb..c984e3d243 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -18,7 +18,7 @@ use super::GUARDIAN_APPROVAL_RISK_THRESHOLD; use super::GUARDIAN_REVIEWER_NAME; use super::GuardianApprovalRequest; use super::GuardianAssessment; -use super::approval_request::guardian_assessment_action_value; +use super::approval_request::guardian_assessment_action; use super::approval_request::guardian_request_id; use super::approval_request::guardian_request_turn_id; use super::prompt::build_guardian_prompt_items; @@ -81,7 +81,7 @@ async fn run_guardian_review( ) -> ReviewDecision { let assessment_id = guardian_request_id(&request).to_string(); let assessment_turn_id = guardian_request_turn_id(&request, &turn.sub_id).to_string(); - let action_summary = guardian_assessment_action_value(&request); + let action_summary = guardian_assessment_action(&request); session .send_event( turn.as_ref(), @@ -92,7 +92,7 @@ async fn run_guardian_review( risk_score: None, risk_level: None, rationale: None, - action: Some(action_summary.clone()), + action: action_summary.clone(), }), ) .await; @@ -111,7 +111,7 @@ async fn run_guardian_review( risk_score: None, risk_level: None, rationale: None, - action: Some(action_summary), + action: action_summary, }), ) .await; @@ -161,7 +161,7 @@ async fn run_guardian_review( risk_score: None, risk_level: None, rationale: None, - action: Some(action_summary), + action: action_summary, }), ) .await; @@ -197,7 +197,7 @@ async fn run_guardian_review( risk_score: Some(assessment.risk_score), risk_level: Some(assessment.risk_level), rationale: Some(assessment.rationale.clone()), - action: Some(terminal_action), + action: terminal_action, }), ) .await; @@ -274,7 +274,7 @@ pub(super) async fn run_guardian_review_session( let available_models = session .services .models_manager - .list_models(crate::models_manager::manager::RefreshStrategy::Offline) + .list_models(codex_models_manager::manager::RefreshStrategy::Offline) .await; let preferred_reasoning_effort = |supports_low: bool, fallback| { if supports_low { diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 85a23898be..be0f38ff85 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; use codex_protocol::user_input::UserInput; use serde_json::Value; @@ -33,11 +34,10 @@ use crate::config::Constrained; use crate::config::ManagedFeatures; use crate::config::NetworkProxySpec; use crate::config::Permissions; -use crate::config::types::McpServerConfig; -use crate::model_provider_info::ModelProviderInfo; -use crate::protocol::SandboxPolicy; use crate::rollout::recorder::RolloutRecorder; +use codex_config::types::McpServerConfig; use codex_features::Feature; +use codex_model_provider_info::ModelProviderInfo; use super::GUARDIAN_REVIEW_TIMEOUT; use super::GUARDIAN_REVIEWER_NAME; @@ -137,7 +137,7 @@ impl GuardianReviewSessionReuseKey { model_reasoning_summary: spawn_config.model_reasoning_summary, permissions: spawn_config.permissions.clone(), developer_instructions: spawn_config.developer_instructions.clone(), - base_instructions: spawn_config.base_instructions.clone(), + base_instructions: spawn_config.base_instructions.clone().flatten(), user_instructions: spawn_config.user_instructions.clone(), compact_prompt: spawn_config.compact_prompt.clone(), cwd: spawn_config.cwd.to_path_buf(), @@ -746,9 +746,13 @@ mod tests { #[tokio::test] async fn guardian_review_session_config_change_invalidates_cached_session() { let parent_config = crate::config::test_config().await; - let cached_spawn_config = - build_guardian_review_session_config(&parent_config, None, "active-model", None) - .expect("cached guardian config"); + let cached_spawn_config = build_guardian_review_session_config( + &parent_config, + /*live_network_config*/ None, + "active-model", + /*reasoning_effort*/ None, + ) + .expect("cached guardian config"); let cached_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(&cached_spawn_config); @@ -757,9 +761,9 @@ mod tests { Some("https://guardian.example.invalid/v1".to_string()); let next_spawn_config = build_guardian_review_session_config( &changed_parent_config, - None, + /*live_network_config*/ None, "active-model", - None, + /*reasoning_effort*/ None, ) .expect("next guardian config"); let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(&next_spawn_config); @@ -775,7 +779,7 @@ mod tests { async fn run_before_review_deadline_times_out_before_future_completes() { let outcome = run_before_review_deadline( tokio::time::Instant::now() + Duration::from_millis(10), - None, + /*external_cancel*/ None, async { tokio::time::sleep(Duration::from_millis(50)).await; }, @@ -816,7 +820,7 @@ mod tests { let outcome = run_before_review_deadline_with_cancel( tokio::time::Instant::now() + Duration::from_millis(10), - None, + /*external_cancel*/ None, &cancel_token, async { tokio::time::sleep(Duration::from_millis(50)).await; @@ -862,7 +866,7 @@ mod tests { let outcome = run_before_review_deadline_with_cancel( tokio::time::Instant::now() + Duration::from_secs(1), - None, + /*external_cancel*/ None, &cancel_token, async { 42usize }, ) diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index cf32645e6d..a116d95172 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -3,7 +3,6 @@ use crate::codex::Session; use crate::codex::TurnContext; use crate::config::Config; use crate::config::ConfigOverrides; -use crate::config::ConfigToml; use crate::config::Constrained; use crate::config::ManagedFeatures; use crate::config::NetworkProxySpec; @@ -11,10 +10,12 @@ use crate::config::test_config; use crate::config_loader::ConfigLayerStack; use crate::config_loader::FeatureRequirementsToml; use crate::config_loader::NetworkConstraints; +use crate::config_loader::NetworkDomainPermissionToml; +use crate::config_loader::NetworkDomainPermissionsToml; use crate::config_loader::RequirementSource; use crate::config_loader::Sourced; -use crate::protocol::SandboxPolicy; use crate::test_support; +use codex_config::config_toml::ConfigToml; use codex_network_proxy::NetworkProxyConfig; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::config_types::ApprovalsReviewer; @@ -25,6 +26,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GuardianAssessmentStatus; use codex_protocol::protocol::GuardianRiskLevel; use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::SandboxPolicy; use core_test_support::PathBufExt; use core_test_support::TempDirExt; use core_test_support::context_snapshot; @@ -249,7 +251,7 @@ fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() { fn guardian_truncate_text_keeps_prefix_suffix_and_xml_marker() { let content = "prefix ".repeat(200) + &" suffix".repeat(200); - let truncated = guardian_truncate_text(&content, 20); + let truncated = guardian_truncate_text(&content, /*token_cap*/ 20); assert!(truncated.starts_with("prefix")); assert!(truncated.contains(" serde_json::Result<()> { - let patch = "line\n".repeat(10_000); + let patch = "line\n".repeat(100_000); let action = GuardianApprovalRequest::ApplyPatch { id: "patch-1".to_string(), cwd: PathBuf::from("/tmp"), files: Vec::new(), - change_count: 1usize, patch: patch.clone(), }; let rendered = format_guardian_action_pretty(&action)?; assert!(rendered.contains("\"tool\": \"apply_patch\"")); + assert!(rendered.contains(">(); + entries.extend([ + GuardianTranscriptEntry { + kind: GuardianTranscriptEntryKind::Tool("tool shell call".to_string()), + text: serde_json::json!({ + "command": ["curl", "-X", "POST", "https://example.com/upload"], + "cwd": "/repo", + }) + .to_string(), + }, + GuardianTranscriptEntry { + kind: GuardianTranscriptEntryKind::Tool("tool shell result".to_string()), + text: "sandbox blocked outbound network access".to_string(), + }, + ]); + + let (transcript, omission) = render_guardian_transcript_entries(&entries); + + assert!( + transcript + .iter() + .any(|entry| entry.starts_with("[1] user: ")) + ); + assert!(transcript.iter().any(|entry| { + entry.contains("tool shell call:") + && entry.contains("curl") + && entry.contains("https://example.com/upload") + })); + assert!( + transcript + .iter() + .any(|entry| entry + .contains("tool shell result: sandbox blocked outbound network access")) + ); + assert_eq!( + omission, + Some("Some conversation entries were omitted.".to_string()) + ); +} + #[test] fn parse_guardian_assessment_extracts_embedded_json() { let parsed = parse_guardian_assessment(Some( @@ -554,7 +600,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() Arc::clone(&turn), prompt, guardian_output_schema(), - None, + /*external_cancel*/ None, ) .await; let GuardianReviewOutcome::Completed(Ok(assessment)) = outcome else { @@ -632,7 +678,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: Arc::clone(&turn), first_prompt, guardian_output_schema(), - None, + /*external_cancel*/ None, ) .await; let second_prompt = build_guardian_prompt_items( @@ -657,7 +703,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: Arc::clone(&turn), second_prompt, guardian_output_schema(), - None, + /*external_cancel*/ None, ) .await; @@ -770,7 +816,7 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() -> additional_permissions: None, justification: Some("Need to push the reviewed docs fix.".to_string()), }, - None, + /*retry_reason*/ None, ) .await; @@ -882,7 +928,7 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a justification: Some("Inspect repo state before proceeding.".to_string()), }; assert_eq!( - review_approval_request(&session, &turn, initial_request, None).await, + review_approval_request(&session, &turn, initial_request, /*retry_reason*/ None).await, ReviewDecision::Approved ); @@ -971,7 +1017,12 @@ async fn guardian_review_session_config_preserves_parent_network_proxy() { NetworkProxyConfig::default(), Some(NetworkConstraints { enabled: Some(true), - allowed_domains: Some(vec!["github.com".to_string()]), + domains: Some(NetworkDomainPermissionsToml { + entries: std::collections::BTreeMap::from([( + "github.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + }), ..Default::default() }), parent_config.permissions.sandbox_policy.get(), @@ -981,7 +1032,7 @@ async fn guardian_review_session_config_preserves_parent_network_proxy() { let guardian_config = build_guardian_review_session_config_for_test( &parent_config, - None, + /*live_network_config*/ None, "parent-active-model", Some(codex_protocol::openai_models::ReasoningEffort::Low), ) @@ -1012,9 +1063,13 @@ async fn guardian_review_session_config_overrides_parent_developer_instructions( parent_config.developer_instructions = Some("parent or managed config should not replace guardian policy".to_string()); - let guardian_config = - build_guardian_review_session_config_for_test(&parent_config, None, "active-model", None) - .expect("guardian config"); + let guardian_config = build_guardian_review_session_config_for_test( + &parent_config, + /*live_network_config*/ None, + "active-model", + /*reasoning_effort*/ None, + ) + .expect("guardian config"); assert_eq!( guardian_config.developer_instructions, @@ -1027,11 +1082,13 @@ async fn guardian_review_session_config_uses_live_network_proxy_state() { let mut parent_config = test_config().await; let mut parent_network = NetworkProxyConfig::default(); parent_network.network.enabled = true; - parent_network.network.allowed_domains = vec!["parent.example".to_string()]; + parent_network + .network + .set_allowed_domains(vec!["parent.example".to_string()]); parent_config.permissions.network = Some( NetworkProxySpec::from_config_and_constraints( parent_network, - None, + /*requirements*/ None, parent_config.permissions.sandbox_policy.get(), ) .expect("parent network proxy spec"), @@ -1039,13 +1096,15 @@ async fn guardian_review_session_config_uses_live_network_proxy_state() { let mut live_network = NetworkProxyConfig::default(); live_network.network.enabled = true; - live_network.network.allowed_domains = vec!["github.com".to_string()]; + live_network + .network + .set_allowed_domains(vec!["github.com".to_string()]); let guardian_config = build_guardian_review_session_config_for_test( &parent_config, Some(live_network.clone()), "active-model", - None, + /*reasoning_effort*/ None, ) .expect("guardian config"); @@ -1054,7 +1113,7 @@ async fn guardian_review_session_config_uses_live_network_proxy_state() { Some( NetworkProxySpec::from_config_and_constraints( live_network, - None, + /*requirements*/ None, &SandboxPolicy::new_read_only_policy(), ) .expect("live network proxy spec") @@ -1076,9 +1135,13 @@ async fn guardian_review_session_config_rejects_pinned_collab_feature() { ) .expect("managed features"); - let err = - build_guardian_review_session_config_for_test(&parent_config, None, "active-model", None) - .expect_err("guardian config should fail when collab is pinned on"); + let err = build_guardian_review_session_config_for_test( + &parent_config, + /*live_network_config*/ None, + "active-model", + /*reasoning_effort*/ None, + ) + .expect_err("guardian config should fail when collab is pinned on"); assert!( err.to_string() @@ -1091,9 +1154,13 @@ async fn guardian_review_session_config_uses_parent_active_model_instead_of_hard let mut parent_config = test_config().await; parent_config.model = Some("configured-model".to_string()); - let guardian_config = - build_guardian_review_session_config_for_test(&parent_config, None, "active-model", None) - .expect("guardian config"); + let guardian_config = build_guardian_review_session_config_for_test( + &parent_config, + /*live_network_config*/ None, + "active-model", + /*reasoning_effort*/ None, + ) + .expect("guardian config"); assert_eq!(guardian_config.model, Some("active-model".to_string())); } @@ -1125,9 +1192,13 @@ async fn guardian_review_session_config_uses_requirements_guardian_override() { .await .expect("load config"); - let guardian_config = - build_guardian_review_session_config_for_test(&parent_config, None, "active-model", None) - .expect("guardian config"); + let guardian_config = build_guardian_review_session_config_for_test( + &parent_config, + /*live_network_config*/ None, + "active-model", + /*reasoning_effort*/ None, + ) + .expect("guardian config"); assert_eq!( guardian_config.developer_instructions, @@ -1155,9 +1226,13 @@ async fn guardian_review_session_config_uses_default_guardian_policy_without_req .await .expect("load config"); - let guardian_config = - build_guardian_review_session_config_for_test(&parent_config, None, "active-model", None) - .expect("guardian config"); + let guardian_config = build_guardian_review_session_config_for_test( + &parent_config, + /*live_network_config*/ None, + "active-model", + /*reasoning_effort*/ None, + ) + .expect("guardian config"); assert_eq!( guardian_config.developer_instructions, diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 744db4886b..0d620119f7 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -1,6 +1,8 @@ use std::future::Future; use std::sync::Arc; +use codex_hooks::PostToolUseOutcome; +use codex_hooks::PostToolUseRequest; use codex_hooks::PreToolUseOutcome; use codex_hooks::PreToolUseRequest; use codex_hooks::SessionStartOutcome; @@ -14,7 +16,9 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::HookCompletedEvent; use codex_protocol::protocol::HookRunSummary; +use codex_protocol::protocol::HookStartedEvent; use codex_protocol::user_input::UserInput; +use serde_json::Value; use crate::codex::Session; use crate::codex::TurnContext; @@ -141,6 +145,33 @@ pub(crate) async fn run_pre_tool_use_hooks( if should_block { block_reason } else { None } } +pub(crate) async fn run_post_tool_use_hooks( + sess: &Arc, + turn_context: &Arc, + tool_use_id: String, + command: String, + tool_response: Value, +) -> PostToolUseOutcome { + let request = PostToolUseRequest { + session_id: sess.conversation_id, + turn_id: turn_context.sub_id.clone(), + cwd: turn_context.cwd.to_path_buf(), + transcript_path: sess.hook_transcript_path().await, + model: turn_context.model_info.slug.clone(), + permission_mode: hook_permission_mode(turn_context), + tool_name: "Bash".to_string(), + tool_use_id, + command, + tool_response, + }; + let preview_runs = sess.hooks().preview_post_tool_use(&request); + emit_hook_started_events(sess, turn_context, preview_runs).await; + + let outcome = sess.hooks().run_post_tool_use(request).await; + emit_hook_completed_events(sess, turn_context, outcome.hook_events.clone()).await; + outcome +} + pub(crate) async fn run_user_prompt_submit_hooks( sess: &Arc, turn_context: &Arc, @@ -276,7 +307,7 @@ async fn emit_hook_started_events( for run in preview_runs { sess.send_event( turn_context, - EventMsg::HookStarted(crate::protocol::HookStartedEvent { + EventMsg::HookStarted(HookStartedEvent { turn_id: Some(turn_context.sub_id.clone()), run, }), diff --git a/codex-rs/core/src/installation_id.rs b/codex-rs/core/src/installation_id.rs new file mode 100644 index 0000000000..940e17eb01 --- /dev/null +++ b/codex-rs/core/src/installation_id.rs @@ -0,0 +1,145 @@ +use std::fs::OpenOptions; +use std::io::Read; +use std::io::Result; +use std::io::Seek; +use std::io::SeekFrom; +use std::io::Write; +use std::path::Path; + +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +use tokio::fs; +use uuid::Uuid; + +pub(crate) const INSTALLATION_ID_FILENAME: &str = "installation_id"; + +pub(crate) async fn resolve_installation_id(codex_home: &Path) -> Result { + let path = codex_home.join(INSTALLATION_ID_FILENAME); + fs::create_dir_all(codex_home).await?; + tokio::task::spawn_blocking(move || { + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + + #[cfg(unix)] + { + options.mode(0o644); + } + + let mut file = options.open(&path)?; + file.lock()?; + + #[cfg(unix)] + { + let metadata = file.metadata()?; + let current_mode = metadata.permissions().mode() & 0o777; + if current_mode != 0o644 { + let mut permissions = metadata.permissions(); + permissions.set_mode(0o644); + file.set_permissions(permissions)?; + } + } + + let mut contents = String::new(); + file.read_to_string(&mut contents)?; + let trimmed = contents.trim(); + if !trimmed.is_empty() + && let Ok(existing) = Uuid::parse_str(trimmed) + { + return Ok(existing.to_string()); + } + + let installation_id = Uuid::new_v4().to_string(); + file.set_len(0)?; + file.seek(SeekFrom::Start(0))?; + file.write_all(installation_id.as_bytes())?; + file.flush()?; + file.sync_all()?; + + Ok(installation_id) + }) + .await? +} + +#[cfg(test)] +mod tests { + use super::INSTALLATION_ID_FILENAME; + use super::resolve_installation_id; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + use uuid::Uuid; + + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + #[tokio::test] + async fn resolve_installation_id_generates_and_persists_uuid() { + let codex_home = TempDir::new().expect("create temp dir"); + let persisted_path = codex_home.path().join(INSTALLATION_ID_FILENAME); + + let installation_id = resolve_installation_id(codex_home.path()) + .await + .expect("resolve installation id"); + + assert_eq!( + std::fs::read_to_string(&persisted_path).expect("read persisted installation id"), + installation_id + ); + assert!(Uuid::parse_str(&installation_id).is_ok()); + + #[cfg(unix)] + { + let mode = std::fs::metadata(&persisted_path) + .expect("read installation id metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o644); + } + } + + #[tokio::test] + async fn resolve_installation_id_reuses_existing_uuid() { + let codex_home = TempDir::new().expect("create temp dir"); + let existing = Uuid::new_v4().to_string().to_uppercase(); + std::fs::write( + codex_home.path().join(INSTALLATION_ID_FILENAME), + existing.clone(), + ) + .expect("write installation id"); + + let resolved = resolve_installation_id(codex_home.path()) + .await + .expect("resolve installation id"); + + assert_eq!( + resolved, + Uuid::parse_str(existing.as_str()) + .expect("parse existing installation id") + .to_string() + ); + } + + #[tokio::test] + async fn resolve_installation_id_rewrites_invalid_file_contents() { + let codex_home = TempDir::new().expect("create temp dir"); + std::fs::write( + codex_home.path().join(INSTALLATION_ID_FILENAME), + "not-a-uuid", + ) + .expect("write invalid installation id"); + + let resolved = resolve_installation_id(codex_home.path()) + .await + .expect("resolve installation id"); + + assert!(Uuid::parse_str(&resolved).is_ok()); + assert_eq!( + std::fs::read_to_string(codex_home.path().join(INSTALLATION_ID_FILENAME)) + .expect("read rewritten installation id"), + resolved + ); + } +} diff --git a/codex-rs/core/src/instructions/mod.rs b/codex-rs/core/src/instructions/mod.rs index 5af9a6e890..a1f77ba5e8 100644 --- a/codex-rs/core/src/instructions/mod.rs +++ b/codex-rs/core/src/instructions/mod.rs @@ -1,2 +1 @@ -pub use codex_instructions::USER_INSTRUCTIONS_PREFIX; pub(crate) use codex_instructions::UserInstructions; diff --git a/codex-rs/core/src/landlock.rs b/codex-rs/core/src/landlock.rs index c1dc4ba542..95f107be9c 100644 --- a/codex-rs/core/src/landlock.rs +++ b/codex-rs/core/src/landlock.rs @@ -1,10 +1,11 @@ -use crate::protocol::SandboxPolicy; use crate::spawn::SpawnChildRequest; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; use codex_network_proxy::NetworkProxy; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::SandboxPolicy; +use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; use codex_sandboxing::landlock::allow_network_for_proxy; use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_policies; use std::collections::HashMap; @@ -48,11 +49,24 @@ where use_legacy_landlock, allow_network_for_proxy(/*enforce_managed_network*/ false), ); - let arg0 = Some("codex-linux-sandbox"); + let codex_linux_sandbox_exe = codex_linux_sandbox_exe.as_ref(); + // Preserve the helper alias when we already have it; otherwise force argv0 + // so arg0 dispatch still reaches the Linux sandbox path. + let arg0 = if codex_linux_sandbox_exe + .file_name() + .and_then(|name| name.to_str()) + == Some(CODEX_LINUX_SANDBOX_ARG0) + { + // Old bubblewrap builds without `--argv0` need a real helper path whose + // basename still dispatches to the Linux sandbox entrypoint. + codex_linux_sandbox_exe.to_string_lossy().into_owned() + } else { + CODEX_LINUX_SANDBOX_ARG0.to_string() + }; spawn_child_async(SpawnChildRequest { - program: codex_linux_sandbox_exe.as_ref().to_path_buf(), + program: codex_linux_sandbox_exe.to_path_buf(), args, - arg0, + arg0: Some(&arg0), cwd: command_cwd, network_sandbox_policy, network, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 446a1a27e5..83232de734 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -5,15 +5,12 @@ // the TUI or the tracing stack). #![deny(clippy::print_stdout, clippy::print_stderr)] -pub mod api_bridge; mod apply_patch; mod apps; mod arc_monitor; -pub use codex_login as auth; -mod auth_env_telemetry; mod client; mod client_common; -pub mod codex; +pub(crate) mod codex; mod realtime_context; mod realtime_conversation; pub use codex::SteerInputError; @@ -30,10 +27,7 @@ pub mod config_loader; pub mod connectors; mod context_manager; mod contextual_user_message; -pub mod custom_prompts; -pub use codex_utils_path::env; mod environment_context; -pub mod error; pub mod exec; pub mod exec_env; mod exec_policy; @@ -44,29 +38,41 @@ mod flags; mod git_info_tests; mod guardian; mod hook_runtime; -pub mod instructions; -pub mod landlock; -pub mod mcp; -mod mcp_connection_manager; +mod installation_id; +pub(crate) mod instructions; +pub(crate) mod landlock; +pub use landlock::spawn_command_under_linux_sandbox; +pub(crate) mod mcp; +mod mcp_skill_dependencies; mod mcp_tool_approval_templates; -pub mod models_manager; mod network_policy_decision; -pub mod network_proxy_loader; +pub(crate) mod network_proxy_loader; +pub use mcp::McpManager; +pub use network_proxy_loader::MtimeConfigReloader; +pub use network_proxy_loader::build_network_proxy_state; +pub use network_proxy_loader::build_network_proxy_state_and_reloader; mod original_image_detail; -mod packages; -pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY; -pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD; -pub use mcp_connection_manager::SandboxState; -pub use text_encoding::bytes_to_string_smart; +pub use codex_mcp::MCP_SANDBOX_STATE_CAPABILITY; +pub use codex_mcp::MCP_SANDBOX_STATE_METHOD; +pub use codex_mcp::SandboxState; mod mcp_tool_call; mod memories; -pub mod mention_syntax; -pub mod message_history; -mod model_provider_info; -pub mod utils; +pub(crate) mod mention_syntax; +pub(crate) mod message_history; +pub(crate) mod utils; +pub use mention_syntax::PLUGIN_TEXT_MENTION_SIGIL; +pub use mention_syntax::TOOL_MENTION_SIGIL; +pub use message_history::HistoryEntry as MessageHistoryEntry; +pub use message_history::append_entry as append_message_history_entry; +pub use message_history::history_metadata as message_history_metadata; +pub use message_history::lookup as lookup_message_history_entry; pub use utils::path_utils; pub mod personality_migration; pub mod plugins; +#[doc(hidden)] +pub(crate) mod prompt_debug; +#[doc(hidden)] +pub use prompt_debug::build_prompt_input; pub(crate) mod mentions { pub(crate) use crate::plugins::build_connector_slug_counts; pub(crate) use crate::plugins::build_skill_name_counts; @@ -95,114 +101,91 @@ pub(crate) use skills::injection; pub(crate) use skills::loader; pub(crate) use skills::manager; pub(crate) use skills::maybe_emit_implicit_skill_invocation; -pub(crate) use skills::model; pub(crate) use skills::render_skills_section; pub(crate) use skills::resolve_skill_dependencies_for_turn; pub(crate) use skills::skills_load_input_from_config; mod skills_watcher; mod stream_events_utils; pub mod test_support; -mod text_encoding; -pub use codex_login::token_data; mod unified_exec; pub mod windows_sandbox; pub use client::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER; -pub use model_provider_info::DEFAULT_LMSTUDIO_PORT; -pub use model_provider_info::DEFAULT_OLLAMA_PORT; -pub use model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; -pub use model_provider_info::ModelProviderInfo; -pub use model_provider_info::OLLAMA_OSS_PROVIDER_ID; -pub use model_provider_info::OPENAI_PROVIDER_ID; -pub use model_provider_info::WireApi; -pub use model_provider_info::built_in_model_providers; -pub use model_provider_info::create_oss_provider_with_base_url; +pub use codex_protocol::config_types::ModelProviderAuthInfo; mod event_mapping; -mod response_debug_context; pub mod review_format; pub mod review_prompts; mod thread_manager; -pub mod web_search; -pub mod windows_sandbox_read_grants; +pub(crate) mod web_search; +pub(crate) mod windows_sandbox_read_grants; pub use thread_manager::ForkSnapshot; pub use thread_manager::NewThread; pub use thread_manager::ThreadManager; +pub use web_search::web_search_action_detail; +pub use web_search::web_search_detail; +pub use windows_sandbox_read_grants::grant_read_root_non_elevated; #[deprecated(note = "use ThreadManager")] pub type ConversationManager = ThreadManager; #[deprecated(note = "use NewThread")] pub type NewConversation = NewThread; #[deprecated(note = "use CodexThread")] pub type CodexConversation = CodexThread; -// Re-export common auth types for workspace consumers -pub use auth::AuthManager; -pub use auth::CodexAuth; -pub use codex_analytics::AnalyticsEventsClient; -mod default_client_forwarding; - -/// Default Codex HTTP client headers and reqwest construction. -/// -/// Implemented in [`codex_login::default_client`]; this module re-exports that API for crates -/// that import `codex_core::default_client`. -pub mod default_client { - pub use super::default_client_forwarding::*; -} -pub mod project_doc; +pub(crate) mod project_doc; +pub use project_doc::DEFAULT_PROJECT_DOC_FILENAME; +pub use project_doc::LOCAL_PROJECT_DOC_FILENAME; +pub use project_doc::discover_project_doc_paths; +pub use project_doc::read_project_docs; mod rollout; pub(crate) mod safety; pub mod seatbelt; mod session_rollout_init_error; pub mod shell; -pub mod shell_snapshot; +pub(crate) mod shell_snapshot; pub mod spawn; -pub mod state_db_bridge; -pub use codex_rollout::state_db; +pub(crate) mod state_db_bridge; +pub use state_db_bridge::StateDbHandle; +pub use state_db_bridge::get_state_db; mod thread_rollout_truncation; mod tools; -pub mod turn_diff_tracker; +pub(crate) mod turn_diff_tracker; mod turn_metadata; mod turn_timing; pub use rollout::ARCHIVED_SESSIONS_SUBDIR; +pub use rollout::Cursor; +pub use rollout::EventPersistenceMode; pub use rollout::INTERACTIVE_SESSION_SOURCES; pub use rollout::RolloutRecorder; pub use rollout::RolloutRecorderParams; pub use rollout::SESSIONS_SUBDIR; pub use rollout::SessionMeta; +pub use rollout::ThreadItem; +pub use rollout::ThreadSortKey; +pub use rollout::ThreadsPage; pub use rollout::append_thread_name; pub use rollout::find_archived_thread_path_by_id_str; #[deprecated(note = "use find_thread_path_by_id_str")] pub use rollout::find_conversation_path_by_id_str; pub use rollout::find_thread_name_by_id; +pub use rollout::find_thread_names_by_ids; pub use rollout::find_thread_path_by_id_str; pub use rollout::find_thread_path_by_name_str; -pub use rollout::list::Cursor; -pub use rollout::list::ThreadItem; -pub use rollout::list::ThreadSortKey; -pub use rollout::list::ThreadsPage; -pub use rollout::list::parse_cursor; -pub use rollout::list::read_head_for_summary; -pub use rollout::list::read_session_meta_line; -pub use rollout::policy::EventPersistenceMode; +pub use rollout::parse_cursor; +pub use rollout::read_head_for_summary; +pub use rollout::read_session_meta_line; pub use rollout::rollout_date_parts; -pub use rollout::session_index::find_thread_names_by_ids; mod function_tool; mod state; mod tasks; mod user_shell_command; pub mod util; -pub(crate) use codex_protocol::protocol; -pub(crate) use codex_shell_command::bash; -pub(crate) use codex_shell_command::is_dangerous_command; -pub(crate) use codex_shell_command::is_safe_command; -pub(crate) use codex_shell_command::parse_command; -pub(crate) use codex_shell_command::powershell; pub use client::ModelClient; pub use client::ModelClientSession; +pub use client::X_CODEX_INSTALLATION_ID_HEADER; pub use client::X_CODEX_TURN_METADATA_HEADER; pub use client_common::Prompt; pub use client_common::REVIEW_PROMPT; pub use client_common::ResponseEvent; pub use client_common::ResponseStream; -pub use codex_sandboxing::get_platform_sandbox; pub use compact::content_items_to_text; pub use event_mapping::parse_turn_item; pub use exec_policy::ExecPolicyError; @@ -210,8 +193,9 @@ pub use exec_policy::check_execpolicy_for_warnings; pub use exec_policy::format_exec_policy_error_with_source; pub use exec_policy::load_exec_policy; pub use file_watcher::FileWatcherEvent; -pub use tools::spec::parse_tool_input_schema; pub use turn_metadata::build_turn_metadata_header; pub mod compact; -pub mod memory_trace; +pub(crate) mod memory_trace; +pub use memory_trace::BuiltMemory; +pub use memory_trace::build_memories_from_trace_files; pub mod otel_init; diff --git a/codex-rs/core/src/mcp.rs b/codex-rs/core/src/mcp.rs new file mode 100644 index 0000000000..83becdc07e --- /dev/null +++ b/codex-rs/core/src/mcp.rs @@ -0,0 +1,41 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use crate::config::Config; +use crate::plugins::PluginsManager; +use codex_config::McpServerConfig; +use codex_login::CodexAuth; +use codex_mcp::ToolPluginProvenance; +use codex_mcp::configured_mcp_servers; +use codex_mcp::effective_mcp_servers; +use codex_mcp::tool_plugin_provenance as collect_tool_plugin_provenance; + +#[derive(Clone)] +pub struct McpManager { + plugins_manager: Arc, +} + +impl McpManager { + pub fn new(plugins_manager: Arc) -> Self { + Self { plugins_manager } + } + + pub fn configured_servers(&self, config: &Config) -> HashMap { + let mcp_config = config.to_mcp_config(self.plugins_manager.as_ref()); + configured_mcp_servers(&mcp_config) + } + + pub fn effective_servers( + &self, + config: &Config, + auth: Option<&CodexAuth>, + ) -> HashMap { + let mcp_config = config.to_mcp_config(self.plugins_manager.as_ref()); + effective_mcp_servers(&mcp_config, auth) + } + + pub fn tool_plugin_provenance(&self, config: &Config) -> ToolPluginProvenance { + let mcp_config = config.to_mcp_config(self.plugins_manager.as_ref()); + collect_tool_plugin_provenance(&mcp_config) + } +} diff --git a/codex-rs/core/src/mcp/skill_dependencies.rs b/codex-rs/core/src/mcp_skill_dependencies.rs similarity index 94% rename from codex-rs/core/src/mcp/skill_dependencies.rs rename to codex-rs/core/src/mcp_skill_dependencies.rs index 489dc2e438..fca38919a0 100644 --- a/codex-rs/core/src/mcp/skill_dependencies.rs +++ b/codex-rs/core/src/mcp_skill_dependencies.rs @@ -1,6 +1,12 @@ use std::collections::HashMap; use std::collections::HashSet; +use codex_config::ConfigEditsBuilder; +use codex_config::McpServerConfig; +use codex_config::McpServerTransportConfig; +use codex_config::load_global_mcp_servers; +use codex_login::default_client::is_first_party_originator; +use codex_login::default_client::originator; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::request_user_input::RequestUserInputArgs; @@ -11,128 +17,19 @@ use codex_rmcp_client::perform_oauth_login; use tokio_util::sync::CancellationToken; use tracing::warn; -use super::auth::McpOAuthLoginSupport; -use super::auth::oauth_login_support; -use super::auth::resolve_oauth_scopes; -use super::auth::should_retry_without_scopes; use crate::SkillMetadata; use crate::codex::Session; use crate::codex::TurnContext; -use crate::config::Config; -use crate::config::edit::ConfigEditsBuilder; -use crate::config::load_global_mcp_servers; -use crate::config::types::McpServerConfig; -use crate::config::types::McpServerTransportConfig; -use crate::default_client::is_first_party_originator; -use crate::default_client::originator; -use crate::model::SkillToolDependency; -use codex_features::Feature; +use crate::skills::model::SkillToolDependency; +use codex_mcp::McpOAuthLoginSupport; +use codex_mcp::oauth_login_support; +use codex_mcp::resolve_oauth_scopes; +use codex_mcp::should_retry_without_scopes; const SKILL_MCP_DEPENDENCY_PROMPT_ID: &str = "skill_mcp_dependency_install"; const MCP_DEPENDENCY_OPTION_INSTALL: &str = "Install"; const MCP_DEPENDENCY_OPTION_SKIP: &str = "Continue anyway"; -fn is_full_access_mode(turn_context: &TurnContext) -> bool { - matches!(turn_context.approval_policy.value(), AskForApproval::Never) - && matches!( - turn_context.sandbox_policy.get(), - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } - ) -} - -fn format_missing_mcp_dependencies(missing: &HashMap) -> String { - let mut names = missing.keys().cloned().collect::>(); - names.sort(); - names.join(", ") -} - -async fn filter_prompted_mcp_dependencies( - sess: &Session, - missing: &HashMap, -) -> HashMap { - let prompted = sess.mcp_dependency_prompted().await; - if prompted.is_empty() { - return missing.clone(); - } - - missing - .iter() - .filter(|(name, config)| !prompted.contains(&canonical_mcp_server_key(name, config))) - .map(|(name, config)| (name.clone(), config.clone())) - .collect() -} - -async fn should_install_mcp_dependencies( - sess: &Session, - turn_context: &TurnContext, - missing: &HashMap, - cancellation_token: &CancellationToken, -) -> bool { - if is_full_access_mode(turn_context) { - return true; - } - - let server_list = format_missing_mcp_dependencies(missing); - let question = RequestUserInputQuestion { - id: SKILL_MCP_DEPENDENCY_PROMPT_ID.to_string(), - header: "Install MCP servers?".to_string(), - question: format!( - "The following MCP servers are required by the selected skills but are not installed yet: {server_list}. Install them now?" - ), - is_other: false, - is_secret: false, - options: Some(vec![ - RequestUserInputQuestionOption { - label: MCP_DEPENDENCY_OPTION_INSTALL.to_string(), - description: - "Install and enable the missing MCP servers in your global config." - .to_string(), - }, - RequestUserInputQuestionOption { - label: MCP_DEPENDENCY_OPTION_SKIP.to_string(), - description: "Skip installation for now and do not show again for these MCP servers in this session." - .to_string(), - }, - ]), - }; - let args = RequestUserInputArgs { - questions: vec![question], - }; - let sub_id = &turn_context.sub_id; - let call_id = format!("mcp-deps-{sub_id}"); - let response_fut = sess.request_user_input(turn_context, call_id, args); - let response = tokio::select! { - biased; - _ = cancellation_token.cancelled() => { - let empty = RequestUserInputResponse { - answers: HashMap::new(), - }; - sess.notify_user_input_response(sub_id, empty.clone()).await; - empty - } - response = response_fut => response.unwrap_or_else(|| RequestUserInputResponse { - answers: HashMap::new(), - }), - }; - - let install = response - .answers - .get(SKILL_MCP_DEPENDENCY_PROMPT_ID) - .is_some_and(|answer| { - answer - .answers - .iter() - .any(|entry| entry == MCP_DEPENDENCY_OPTION_INSTALL) - }); - - let prompted_keys = missing - .iter() - .map(|(name, config)| canonical_mcp_server_key(name, config)); - sess.record_mcp_dependency_prompted(prompted_keys).await; - - install -} - pub(crate) async fn maybe_prompt_and_install_mcp_dependencies( sess: &Session, turn_context: &TurnContext, @@ -146,7 +43,11 @@ pub(crate) async fn maybe_prompt_and_install_mcp_dependencies( } let config = turn_context.config.clone(); - if mentioned_skills.is_empty() || !config.features.enabled(Feature::SkillMcpDependencyInstall) { + if mentioned_skills.is_empty() + || !config + .features + .enabled(codex_features::Feature::SkillMcpDependencyInstall) + { return; } @@ -174,10 +75,14 @@ pub(crate) async fn maybe_prompt_and_install_mcp_dependencies( pub(crate) async fn maybe_install_mcp_dependencies( sess: &Session, turn_context: &TurnContext, - config: &Config, + config: &crate::config::Config, mentioned_skills: &[SkillMetadata], ) { - if mentioned_skills.is_empty() || !config.features.enabled(Feature::SkillMcpDependencyInstall) { + if mentioned_skills.is_empty() + || !config + .features + .enabled(codex_features::Feature::SkillMcpDependencyInstall) + { return; } @@ -307,6 +212,107 @@ pub(crate) async fn maybe_install_mcp_dependencies( .await; } +async fn should_install_mcp_dependencies( + sess: &Session, + turn_context: &TurnContext, + missing: &HashMap, + cancellation_token: &CancellationToken, +) -> bool { + if is_full_access_mode(turn_context) { + return true; + } + + let server_list = format_missing_mcp_dependencies(missing); + let question = RequestUserInputQuestion { + id: SKILL_MCP_DEPENDENCY_PROMPT_ID.to_string(), + header: "Install MCP servers?".to_string(), + question: format!( + "The following MCP servers are required by the selected skills but are not installed yet: {server_list}. Install them now?" + ), + is_other: false, + is_secret: false, + options: Some(vec![ + RequestUserInputQuestionOption { + label: MCP_DEPENDENCY_OPTION_INSTALL.to_string(), + description: + "Install and enable the missing MCP servers in your global config." + .to_string(), + }, + RequestUserInputQuestionOption { + label: MCP_DEPENDENCY_OPTION_SKIP.to_string(), + description: "Skip installation for now and do not show again for these MCP servers in this session." + .to_string(), + }, + ]), + }; + let args = RequestUserInputArgs { + questions: vec![question], + }; + let sub_id = &turn_context.sub_id; + let call_id = format!("mcp-deps-{sub_id}"); + let response_fut = sess.request_user_input(turn_context, call_id, args); + let response = tokio::select! { + biased; + _ = cancellation_token.cancelled() => { + let empty = RequestUserInputResponse { + answers: HashMap::new(), + }; + sess.notify_user_input_response(sub_id, empty.clone()).await; + empty + } + response = response_fut => response.unwrap_or_else(|| RequestUserInputResponse { + answers: HashMap::new(), + }), + }; + + let install = response + .answers + .get(SKILL_MCP_DEPENDENCY_PROMPT_ID) + .is_some_and(|answer| { + answer + .answers + .iter() + .any(|entry| entry == MCP_DEPENDENCY_OPTION_INSTALL) + }); + + let prompted_keys = missing + .iter() + .map(|(name, config)| canonical_mcp_server_key(name, config)); + sess.record_mcp_dependency_prompted(prompted_keys).await; + + install +} + +async fn filter_prompted_mcp_dependencies( + sess: &Session, + missing: &HashMap, +) -> HashMap { + let prompted = sess.mcp_dependency_prompted().await; + if prompted.is_empty() { + return missing.clone(); + } + + missing + .iter() + .filter(|(name, config)| !prompted.contains(&canonical_mcp_server_key(name, config))) + .map(|(name, config)| (name.clone(), config.clone())) + .collect() +} + +fn format_missing_mcp_dependencies(missing: &HashMap) -> String { + let mut names = missing.keys().cloned().collect::>(); + names.sort(); + names.join(", ") +} + +fn is_full_access_mode(turn_context: &TurnContext) -> bool { + matches!(turn_context.approval_policy.value(), AskForApproval::Never) + && matches!( + turn_context.sandbox_policy.get(), + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } + ) +} + fn canonical_mcp_key(transport: &str, identifier: &str, fallback: &str) -> String { let identifier = identifier.trim(); if identifier.is_empty() { @@ -346,7 +352,65 @@ fn canonical_mcp_dependency_key(dependency: &SkillToolDependency) -> Result 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, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: 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, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + tools: HashMap::new(), + }); + } + + Err(format!("unsupported transport {transport}")) +} + +fn collect_missing_mcp_dependencies( mentioned_skills: &[SkillMetadata], installed: &HashMap, ) -> HashMap { @@ -402,63 +466,3 @@ pub(crate) fn collect_missing_mcp_dependencies( missing } - -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, - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - }); - } - - 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, - }, - enabled: true, - required: false, - disabled_reason: None, - startup_timeout_sec: None, - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - }); - } - - Err(format!("unsupported transport {transport}")) -} - -#[cfg(test)] -#[path = "skill_dependencies_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/mcp_tool_approval_templates.rs b/codex-rs/core/src/mcp_tool_approval_templates.rs index b1e0ee27d9..66002ab5ab 100644 --- a/codex-rs/core/src/mcp_tool_approval_templates.rs +++ b/codex-rs/core/src/mcp_tool_approval_templates.rs @@ -330,7 +330,7 @@ mod tests { &templates, "codex_apps", Some("github"), - None, + /*connector_name*/ None, Some("add_comment"), Some(&json!({})), ); @@ -361,7 +361,7 @@ mod tests { &templates, "codex_apps", Some("calendar"), - None, + /*connector_name*/ None, Some("create_event"), Some(&json!({})), ), diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index d31696db5c..2a94d13f89 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -1,7 +1,10 @@ use std::collections::BTreeMap; +use std::collections::HashMap; +use std::path::PathBuf; use std::time::Duration; use std::time::Instant; +use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::McpElicitationObjectType; use codex_app_server_protocol::McpElicitationSchema; use codex_app_server_protocol::McpServerElicitationRequest; @@ -12,30 +15,32 @@ use crate::arc_monitor::ArcMonitorOutcome; use crate::arc_monitor::monitor_action; use crate::codex::Session; use crate::codex::TurnContext; +use crate::config::Config; use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; -use crate::config::types::AppToolApproval; +use crate::config::load_global_mcp_servers; use crate::connectors; use crate::guardian::GuardianApprovalRequest; use crate::guardian::GuardianMcpAnnotations; use crate::guardian::guardian_approval_request_to_json; use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; -use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp_tool_approval_templates::RenderedMcpToolApprovalParam; use crate::mcp_tool_approval_templates::render_mcp_tool_approval_template; -use crate::protocol::EventMsg; -use crate::protocol::McpInvocation; -use crate::protocol::McpToolCallBeginEvent; -use crate::protocol::McpToolCallEndEvent; -use crate::state_db; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::build_track_events_context; +use codex_config::types::AppToolApproval; use codex_features::Feature; +use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_otel::sanitize_metric_tag_value; use codex_protocol::mcp::CallToolResult; use codex_protocol::openai_models::InputModality; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::McpInvocation; +use codex_protocol::protocol::McpToolCallBeginEvent; +use codex_protocol::protocol::McpToolCallEndEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::request_user_input::RequestUserInputAnswer; @@ -45,11 +50,20 @@ use codex_protocol::request_user_input::RequestUserInputQuestionOption; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationAction; use codex_rmcp_client::ElicitationResponse; +use codex_rollout::state_db; use rmcp::model::ToolAnnotations; +use serde::Deserialize; use serde::Serialize; use std::path::Path; use std::sync::Arc; use toml_edit::value; +use tracing::Instrument; +use tracing::Span; +use tracing::field::Empty; +use url::Url; + +const MCP_CALL_COUNT_METRIC: &str = "codex.mcp.call"; +const MCP_CALL_DURATION_METRIC: &str = "codex.mcp.call.duration_ms"; /// Handles the specified tool call dispatches the appropriate /// `McpToolCallBegin` and `McpToolCallEnd` events to the `Session`. @@ -100,6 +114,11 @@ pub(crate) async fn handle_mcp_tool_call( } else { connectors::AppToolPolicy::default() }; + let approval_mode = if server == CODEX_APPS_MCP_SERVER_NAME { + app_tool_policy.approval + } else { + custom_mcp_tool_approval_mode(turn_context.as_ref(), &server, &tool_name) + }; if server == CODEX_APPS_MCP_SERVER_NAME && !app_tool_policy.enabled { let result = notify_mcp_tool_call_skip( @@ -113,7 +132,7 @@ pub(crate) async fn handle_mcp_tool_call( .await; let status = if result.is_ok() { "ok" } else { "error" }; turn_context.session_telemetry.counter( - "codex.mcp.call", + MCP_CALL_COUNT_METRIC, /*inc*/ 1, &[("status", status)], ); @@ -121,6 +140,19 @@ pub(crate) async fn handle_mcp_tool_call( } let request_meta = build_mcp_tool_call_request_meta(turn_context.as_ref(), &server, metadata.as_ref()); + let connector_id = metadata + .as_ref() + .and_then(|metadata| metadata.connector_id.clone()); + let connector_name = metadata + .as_ref() + .and_then(|metadata| metadata.connector_name.clone()); + let server_origin = sess + .services + .mcp_connection_manager + .read() + .await + .server_origin(&server) + .map(str::to_string); let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent { call_id: call_id.clone(), @@ -134,26 +166,40 @@ pub(crate) async fn handle_mcp_tool_call( &call_id, &invocation, metadata.as_ref(), - app_tool_policy.approval, + approval_mode, ) .await { - let result = match decision { + let (result, call_duration) = match decision { McpToolApprovalDecision::Accept | McpToolApprovalDecision::AcceptForSession | McpToolApprovalDecision::AcceptAndRemember => { maybe_mark_thread_memory_mode_polluted(sess.as_ref(), turn_context.as_ref()).await; let start = Instant::now(); - let result = sess - .call_tool( + let result = async { + sess.call_tool( &server, &tool_name, arguments_value.clone(), request_meta.clone(), ) .await - .map_err(|e| format!("tool call error: {e:?}")); + .map_err(|e| format!("tool call error: {e:?}")) + } + .instrument(mcp_tool_call_span( + sess.as_ref(), + turn_context.as_ref(), + McpToolCallSpanFields { + server_name: &server, + tool_name: &tool_name, + call_id: &call_id, + server_origin: server_origin.as_deref(), + connector_id: connector_id.as_deref(), + connector_name: connector_name.as_deref(), + }, + )) + .await; let result = sanitize_mcp_tool_result_for_model( turn_context .model_info @@ -161,13 +207,14 @@ pub(crate) async fn handle_mcp_tool_call( .contains(&InputModality::Image), result, ); - if let Err(e) = &result { - tracing::warn!("MCP tool call error: {e:?}"); + if let Err(error) = &result { + tracing::warn!("MCP tool call error: {error:?}"); } + let duration = start.elapsed(); let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent { call_id: call_id.clone(), invocation, - duration: start.elapsed(), + duration, result: result.clone(), }); notify_mcp_tool_call_event( @@ -183,50 +230,62 @@ pub(crate) async fn handle_mcp_tool_call( &tool_name, ) .await; - result + (result, Some(duration)) } McpToolApprovalDecision::Decline => { let message = "user rejected MCP tool call".to_string(); - notify_mcp_tool_call_skip( - sess.as_ref(), - turn_context.as_ref(), - &call_id, - invocation, - message, - /*already_started*/ true, + ( + notify_mcp_tool_call_skip( + sess.as_ref(), + turn_context.as_ref(), + &call_id, + invocation, + message, + /*already_started*/ true, + ) + .await, + None, ) - .await } McpToolApprovalDecision::Cancel => { let message = "user cancelled MCP tool call".to_string(); - notify_mcp_tool_call_skip( - sess.as_ref(), - turn_context.as_ref(), - &call_id, - invocation, - message, - /*already_started*/ true, + ( + notify_mcp_tool_call_skip( + sess.as_ref(), + turn_context.as_ref(), + &call_id, + invocation, + message, + /*already_started*/ true, + ) + .await, + None, ) - .await } McpToolApprovalDecision::BlockedBySafetyMonitor(message) => { - notify_mcp_tool_call_skip( - sess.as_ref(), - turn_context.as_ref(), - &call_id, - invocation, - message, - /*already_started*/ true, + ( + notify_mcp_tool_call_skip( + sess.as_ref(), + turn_context.as_ref(), + &call_id, + invocation, + message, + /*already_started*/ true, + ) + .await, + None, ) - .await } }; let status = if result.is_ok() { "ok" } else { "error" }; - turn_context.session_telemetry.counter( - "codex.mcp.call", - /*inc*/ 1, - &[("status", status)], + emit_mcp_call_metrics( + turn_context.as_ref(), + status, + &tool_name, + connector_id.as_deref(), + connector_name.as_deref(), + call_duration, ); return CallToolResult::from_result(result); @@ -236,10 +295,24 @@ pub(crate) async fn handle_mcp_tool_call( let start = Instant::now(); // Perform the tool call. - let result = sess - .call_tool(&server, &tool_name, arguments_value.clone(), request_meta) - .await - .map_err(|e| format!("tool call error: {e:?}")); + let result = async { + sess.call_tool(&server, &tool_name, arguments_value.clone(), request_meta) + .await + .map_err(|e| format!("tool call error: {e:?}")) + } + .instrument(mcp_tool_call_span( + sess.as_ref(), + turn_context.as_ref(), + McpToolCallSpanFields { + server_name: &server, + tool_name: &tool_name, + call_id: &call_id, + server_origin: server_origin.as_deref(), + connector_id: connector_id.as_deref(), + connector_name: connector_name.as_deref(), + }, + )) + .await; let result = sanitize_mcp_tool_result_for_model( turn_context .model_info @@ -247,13 +320,14 @@ pub(crate) async fn handle_mcp_tool_call( .contains(&InputModality::Image), result, ); - if let Err(e) = &result { - tracing::warn!("MCP tool call error: {e:?}"); + if let Err(error) = &result { + tracing::warn!("MCP tool call error: {error:?}"); } + let duration = start.elapsed(); let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent { call_id: call_id.clone(), invocation, - duration: start.elapsed(), + duration, result: result.clone(), }); @@ -266,13 +340,119 @@ pub(crate) async fn handle_mcp_tool_call( maybe_track_codex_app_used(sess.as_ref(), turn_context.as_ref(), &server, &tool_name).await; let status = if result.is_ok() { "ok" } else { "error" }; - turn_context - .session_telemetry - .counter("codex.mcp.call", /*inc*/ 1, &[("status", status)]); + emit_mcp_call_metrics( + turn_context.as_ref(), + status, + &tool_name, + connector_id.as_deref(), + connector_name.as_deref(), + Some(duration), + ); CallToolResult::from_result(result) } +fn emit_mcp_call_metrics( + turn_context: &TurnContext, + status: &str, + tool_name: &str, + connector_id: Option<&str>, + connector_name: Option<&str>, + duration: Option, +) { + let tags = mcp_call_metric_tags(status, tool_name, connector_id, connector_name); + let tag_refs: Vec<(&str, &str)> = tags + .iter() + .map(|(key, value)| (*key, value.as_str())) + .collect(); + turn_context + .session_telemetry + .counter(MCP_CALL_COUNT_METRIC, /*inc*/ 1, &tag_refs); + if let Some(duration) = duration { + turn_context.session_telemetry.record_duration( + MCP_CALL_DURATION_METRIC, + duration, + &tag_refs, + ); + } +} + +fn mcp_call_metric_tags( + status: &str, + tool_name: &str, + connector_id: Option<&str>, + connector_name: Option<&str>, +) -> Vec<(&'static str, String)> { + let mut tags = vec![ + ("status", sanitize_metric_tag_value(status)), + ("tool", sanitize_metric_tag_value(tool_name)), + ]; + if let Some(connector_id) = connector_id.filter(|connector_id| !connector_id.is_empty()) { + tags.push(("connector_id", sanitize_metric_tag_value(connector_id))); + } + if let Some(connector_name) = connector_name.filter(|connector_name| !connector_name.is_empty()) + { + tags.push(("connector_name", sanitize_metric_tag_value(connector_name))); + } + tags +} + +fn mcp_tool_call_span( + session: &Session, + turn_context: &TurnContext, + fields: McpToolCallSpanFields<'_>, +) -> Span { + let transport = match fields.server_origin { + Some("stdio") => "stdio", + Some(_) => "streamable_http", + None => "", + }; + let span = tracing::info_span!( + "mcp.tools.call", + otel.kind = "client", + rpc.system = "jsonrpc", + rpc.method = "tools/call", + mcp.server.name = fields.server_name, + mcp.server.origin = fields.server_origin.unwrap_or(""), + mcp.transport = transport, + mcp.connector.id = fields.connector_id.unwrap_or(""), + mcp.connector.name = fields.connector_name.unwrap_or(""), + tool.name = fields.tool_name, + tool.call_id = fields.call_id, + conversation.id = %session.conversation_id, + session.id = %session.conversation_id, + turn.id = turn_context.sub_id.as_str(), + server.address = Empty, + server.port = Empty, + ); + record_server_fields(&span, fields.server_origin); + span +} + +struct McpToolCallSpanFields<'a> { + server_name: &'a str, + tool_name: &'a str, + call_id: &'a str, + server_origin: Option<&'a str>, + connector_id: Option<&'a str>, + connector_name: Option<&'a str>, +} + +fn record_server_fields(span: &Span, url: Option<&str>) { + let Some(url) = url else { + return; + }; + let Ok(parsed) = Url::parse(url) else { + return; + }; + if let Some(host) = parsed.host_str() { + span.record("server.address", host); + } + if let Some(port) = parsed.port_or_known_default() { + span.record("server.port", port as i64); + } +} + async fn maybe_mark_thread_memory_mode_polluted(sess: &Session, turn_context: &TurnContext) { if !turn_context .config @@ -390,6 +570,27 @@ pub(crate) struct McpToolApprovalMetadata { const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps"; +fn custom_mcp_tool_approval_mode( + turn_context: &TurnContext, + server: &str, + tool_name: &str, +) -> AppToolApproval { + turn_context + .config + .config_layer_stack + .effective_config() + .as_table() + .and_then(|table| table.get("mcp_servers")) + .cloned() + .and_then(|value| { + HashMap::::deserialize(value).ok() + }) + .and_then(|servers| servers.get(server).cloned()) + .and_then(|server| server.tools.get(tool_name).cloned()) + .and_then(|tool| tool.approval_mode) + .unwrap_or_default() +} + fn build_mcp_tool_call_request_meta( turn_context: &TurnContext, server: &str, @@ -459,7 +660,6 @@ const MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: &str = "tool_params"; const MCP_TOOL_APPROVAL_TOOL_PARAMS_DISPLAY_KEY: &str = "tool_params_display"; const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_DEFAULT: &str = "mcp_tool_call__default"; const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_ALWAYS_ALLOW: &str = "mcp_tool_call__always_allow"; -const MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_FULL_ACCESS: &str = "mcp_tool_call__full_access"; pub(crate) fn is_mcp_tool_approval_question_id(question_id: &str) -> bool { question_id @@ -494,17 +694,20 @@ async fn maybe_request_mcp_tool_approval( metadata: Option<&McpToolApprovalMetadata>, approval_mode: AppToolApproval, ) -> Option { + if is_full_access_mode(turn_context) { + return None; + } + let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref()); let approval_required = requires_mcp_tool_approval(annotations); + if !approval_required && approval_mode != AppToolApproval::Prompt { + return None; + } + let mut monitor_reason = None; - let auto_approved_by_policy = approval_mode == AppToolApproval::Approve - || (approval_mode == AppToolApproval::Auto && is_full_access_mode(turn_context)); + let auto_approved_by_policy = approval_mode == AppToolApproval::Approve; if auto_approved_by_policy { - if !approval_required { - return None; - } - match maybe_monitor_auto_approved_mcp_tool_call( sess, turn_context, @@ -526,10 +729,6 @@ async fn maybe_request_mcp_tool_approval( } } - if approval_mode == AppToolApproval::Auto && !approval_required { - return None; - } - let session_approval_key = session_mcp_tool_approval_key(invocation, metadata, approval_mode); let persistent_approval_key = persistent_mcp_tool_approval_key(invocation, metadata, approval_mode); @@ -711,12 +910,7 @@ fn persistent_mcp_tool_approval_key( metadata: Option<&McpToolApprovalMetadata>, approval_mode: AppToolApproval, ) -> Option { - if invocation.server != CODEX_APPS_MCP_SERVER_NAME { - return None; - } - session_mcp_tool_approval_key(invocation, metadata, approval_mode) - .filter(|key| key.connector_id.is_some()) } pub(crate) fn build_guardian_mcp_tool_review_request( @@ -764,16 +958,12 @@ fn is_full_access_mode(turn_context: &TurnContext) -> bool { fn mcp_tool_approval_callsite_mode( approval_mode: AppToolApproval, - turn_context: &TurnContext, + _turn_context: &TurnContext, ) -> &'static str { match approval_mode { AppToolApproval::Approve => MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_ALWAYS_ALLOW, AppToolApproval::Auto | AppToolApproval::Prompt => { - if approval_mode == AppToolApproval::Auto && is_full_access_mode(turn_context) { - MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_FULL_ACCESS - } else { - MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_DEFAULT - } + MCP_TOOL_CALL_ARC_MONITOR_CALLSITE_DEFAULT } } } @@ -1255,21 +1445,25 @@ async fn maybe_persist_mcp_tool_approval( turn_context: &TurnContext, key: McpToolApprovalKey, ) { - let Some(connector_id) = key.connector_id.clone() else { - remember_mcp_tool_approval(sess, key).await; - return; - }; let tool_name = key.tool_name.clone(); - if let Err(err) = + let persist_result = if key.server == CODEX_APPS_MCP_SERVER_NAME { + let Some(connector_id) = key.connector_id.clone() else { + remember_mcp_tool_approval(sess, key).await; + return; + }; persist_codex_app_tool_approval(&turn_context.config.codex_home, &connector_id, &tool_name) .await - { + } else { + persist_custom_mcp_tool_approval(&turn_context.config, &key.server, &tool_name).await + }; + + if let Err(err) = persist_result { error!( error = %err, - connector_id, + server = key.server, tool_name, - "failed to persist codex app tool approval" + "failed to persist MCP tool approval" ); remember_mcp_tool_approval(sess, key).await; return; @@ -1299,6 +1493,66 @@ async fn persist_codex_app_tool_approval( .await } +async fn persist_custom_mcp_tool_approval( + config: &Config, + server: &str, + tool_name: &str, +) -> anyhow::Result<()> { + let config_folder = if let Some(project_config_folder) = + project_mcp_tool_approval_config_folder(config, server) + { + project_config_folder + } else { + let servers = load_global_mcp_servers(&config.codex_home).await?; + if !servers.contains_key(server) { + anyhow::bail!("MCP server `{server}` is not configured in config.toml"); + } + config.codex_home.clone() + }; + + ConfigEditsBuilder::new(&config_folder) + .with_edits([ConfigEdit::SetPath { + segments: vec![ + "mcp_servers".to_string(), + server.to_string(), + "tools".to_string(), + tool_name.to_string(), + "approval_mode".to_string(), + ], + value: value("approve"), + }]) + .apply() + .await +} + +fn project_mcp_tool_approval_config_folder(config: &Config, server: &str) -> Option { + config + .config_layer_stack + .layers_high_to_low() + .into_iter() + .find_map(|layer| { + if !matches!(layer.name, ConfigLayerSource::Project { .. }) { + return None; + } + + let servers = layer + .config + .as_table() + .and_then(|table| table.get("mcp_servers")) + .cloned() + .and_then(|value| { + HashMap::::deserialize(value).ok() + })?; + if servers.contains_key(server) { + layer + .config_folder() + .map(|folder| folder.as_path().to_path_buf()) + } else { + None + } + }) +} + fn requires_mcp_tool_approval(annotations: Option<&ToolAnnotations>) -> bool { let destructive_hint = annotations.and_then(|annotations| annotations.destructive_hint); if destructive_hint == Some(true) { diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 9d7d1c3a41..b313a43e71 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1,12 +1,17 @@ use super::*; use crate::codex::make_session_and_context; -use crate::config::ApprovalsReviewer; -use crate::config::ConfigToml; -use crate::config::types::AppConfig; -use crate::config::types::AppToolConfig; -use crate::config::types::AppToolsConfig; -use crate::config::types::AppsConfigToml; +use crate::codex::make_session_and_context_with_rx; +use crate::config::ConfigBuilder; +use crate::state::ActiveTurn; use codex_config::CONFIG_TOML_FILE; +use codex_config::config_toml::ConfigToml; +use codex_config::types::AppConfig; +use codex_config::types::AppToolConfig; +use codex_config::types::AppToolsConfig; +use codex_config::types::ApprovalsReviewer; +use codex_config::types::AppsConfigToml; +use codex_config::types::McpServerConfig; +use codex_config::types::McpServerToolConfig; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; @@ -18,6 +23,10 @@ use serde::Deserialize; use std::collections::HashMap; use std::sync::Arc; use tempfile::tempdir; +use tracing::Instrument; +use tracing::Level; +use tracing_subscriber::fmt::format::FmtSpan; +use tracing_test::internal::MockWriter; fn annotations( read_only: Option, @@ -63,13 +72,13 @@ fn prompt_options( #[test] fn approval_required_when_read_only_false_and_destructive() { - let annotations = annotations(Some(false), Some(true), None); + let annotations = annotations(Some(false), Some(true), /*open_world*/ None); assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true); } #[test] fn approval_required_when_read_only_false_and_open_world() { - let annotations = annotations(Some(false), None, Some(true)); + let annotations = annotations(Some(false), /*destructive*/ None, Some(true)); assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true); } @@ -81,12 +90,16 @@ fn approval_required_when_destructive_even_if_read_only_true() { #[test] fn approval_required_when_annotations_are_absent() { - assert_eq!(requires_mcp_tool_approval(None), true); + assert_eq!(requires_mcp_tool_approval(/*annotations*/ None), true); } #[test] fn approval_not_required_when_read_only_and_other_hints_are_absent() { - let annotations = annotations(Some(true), None, None); + let annotations = annotations( + Some(true), + /*destructive*/ None, + /*open_world*/ None, + ); assert_eq!(requires_mcp_tool_approval(Some(&annotations)), false); } @@ -119,6 +132,57 @@ fn approval_question_text_prepends_safety_reason() { ); } +#[tokio::test] +async fn mcp_tool_call_span_records_expected_fields() { + let buffer: &'static std::sync::Mutex> = + Box::leak(Box::new(std::sync::Mutex::new(Vec::new()))); + let subscriber = tracing_subscriber::fmt() + .with_level(true) + .with_ansi(false) + .with_max_level(Level::TRACE) + .with_span_events(FmtSpan::FULL) + .with_writer(MockWriter::new(buffer)) + .finish(); + let _guard = tracing::subscriber::set_default(subscriber); + + let (session, turn_context) = make_session_and_context().await; + + async {} + .instrument(mcp_tool_call_span( + &session, + &turn_context, + McpToolCallSpanFields { + server_name: "rmcp", + tool_name: "echo", + call_id: "call-123", + server_origin: Some("https://example.com:8443/mcp"), + connector_id: Some("calendar"), + connector_name: Some("Calendar"), + }, + )) + .await; + + let logs = String::from_utf8(buffer.lock().expect("buffer lock").clone()).expect("utf8 logs"); + assert!( + logs.contains("mcp.tools.call{otel.kind=\"client\"") + && logs.contains("rpc.system=\"jsonrpc\"") + && logs.contains("rpc.method=\"tools/call\"") + && logs.contains("mcp.server.name=\"rmcp\"") + && logs.contains("mcp.server.origin=\"https://example.com:8443/mcp\"") + && logs.contains("mcp.transport=\"streamable_http\"") + && logs.contains("mcp.connector.id=\"calendar\"") + && logs.contains("mcp.connector.name=\"Calendar\"") + && logs.contains("tool.name=\"echo\"") + && logs.contains("tool.call_id=\"call-123\"") + && logs.contains("server.address=\"example.com\"") + && logs.contains("server.port=8443") + && logs.contains("conversation.id=") + && logs.contains("session.id=") + && logs.contains("turn.id="), + "missing MCP tool span fields\nlogs:\n{logs}" + ); +} + #[tokio::test] async fn approval_elicitation_request_uses_message_override_and_preserves_tool_params_keys() { let (session, turn_context) = make_session_and_context().await; @@ -127,7 +191,9 @@ async fn approval_elicitation_request_uses_message_override_and_preserves_tool_p CODEX_APPS_MCP_SERVER_NAME, "create_event", Some("Calendar"), - prompt_options(true, true), + prompt_options( + /*allow_session_remember*/ true, /*allow_persistent_approval*/ true, + ), Some("Allow Calendar to create an event?"), ); @@ -161,7 +227,9 @@ async fn approval_elicitation_request_uses_message_override_and_preserves_tool_p ]), question, message_override: Some("Allow Calendar to create an event?"), - prompt_options: prompt_options(true, true), + prompt_options: prompt_options( + /*allow_session_remember*/ true, /*allow_persistent_approval*/ true, + ), }, ); @@ -219,9 +287,11 @@ fn custom_mcp_tool_question_mentions_server_name() { "q".to_string(), "custom_server", "run_action", - None, - prompt_options(false, false), - None, + /*connector_name*/ None, + prompt_options( + /*allow_session_remember*/ false, /*allow_persistent_approval*/ false, + ), + /*question_override*/ None, ); assert_eq!(question.header, "Approve app tool call?"); @@ -245,9 +315,11 @@ fn codex_apps_tool_question_uses_fallback_app_label() { "q".to_string(), CODEX_APPS_MCP_SERVER_NAME, "run_action", - None, - prompt_options(true, true), - None, + /*connector_name*/ None, + prompt_options( + /*allow_session_remember*/ true, /*allow_persistent_approval*/ true, + ), + /*question_override*/ None, ); assert_eq!( @@ -263,8 +335,10 @@ fn trusted_codex_apps_tool_question_offers_always_allow() { CODEX_APPS_MCP_SERVER_NAME, "run_action", Some("Calendar"), - prompt_options(true, true), - None, + prompt_options( + /*allow_session_remember*/ true, /*allow_persistent_approval*/ true, + ), + /*question_override*/ None, ); let options = question.options.expect("options"); @@ -303,8 +377,12 @@ fn codex_apps_tool_question_without_elicitation_omits_always_allow() { CODEX_APPS_MCP_SERVER_NAME, "run_action", Some("Calendar"), - mcp_tool_approval_prompt_options(Some(&session_key), Some(&persistent_key), false), - None, + mcp_tool_approval_prompt_options( + Some(&session_key), + Some(&persistent_key), + /*tool_call_mcp_elicitation_enabled*/ false, + ), + /*question_override*/ None, ); assert_eq!( @@ -323,14 +401,16 @@ fn codex_apps_tool_question_without_elicitation_omits_always_allow() { } #[test] -fn custom_mcp_tool_question_offers_session_remember_without_always_allow() { +fn custom_mcp_tool_question_offers_session_remember_and_always_allow() { let question = build_mcp_tool_approval_question( "q".to_string(), "custom_server", "run_action", - None, - prompt_options(true, false), - None, + /*connector_name*/ None, + prompt_options( + /*allow_session_remember*/ true, /*allow_persistent_approval*/ true, + ), + /*question_override*/ None, ); assert_eq!( @@ -343,13 +423,14 @@ fn custom_mcp_tool_question_offers_session_remember_without_always_allow() { vec![ MCP_TOOL_APPROVAL_ACCEPT.to_string(), MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(), + MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER.to_string(), MCP_TOOL_APPROVAL_CANCEL.to_string(), ] ); } #[test] -fn custom_servers_keep_session_remember_without_persistent_approval() { +fn custom_servers_support_session_and_persistent_approval() { let invocation = McpInvocation { server: "custom_server".to_string(), tool: "run_action".to_string(), @@ -362,12 +443,16 @@ fn custom_servers_keep_session_remember_without_persistent_approval() { }; assert_eq!( - session_mcp_tool_approval_key(&invocation, None, AppToolApproval::Auto), - Some(expected) + session_mcp_tool_approval_key(&invocation, /*metadata*/ None, AppToolApproval::Auto), + Some(expected.clone()) ); assert_eq!( - persistent_mcp_tool_approval_key(&invocation, None, AppToolApproval::Auto), - None + persistent_mcp_tool_approval_key( + &invocation, + /*metadata*/ None, + AppToolApproval::Auto + ), + Some(expected) ); } @@ -378,7 +463,13 @@ fn codex_apps_connectors_support_persistent_approval() { tool: "calendar/list_events".to_string(), arguments: None, }; - let metadata = approval_metadata(Some("calendar"), Some("Calendar"), None, None, None); + let metadata = approval_metadata( + Some("calendar"), + Some("Calendar"), + /*connector_description*/ None, + /*tool_title*/ None, + /*tool_description*/ None, + ); let expected = McpToolApprovalKey { server: CODEX_APPS_MCP_SERVER_NAME.to_string(), connector_id: Some("calendar".to_string()), @@ -414,7 +505,8 @@ fn sanitize_mcp_tool_result_for_model_rewrites_image_content() { meta: None, }); - let got = sanitize_mcp_tool_result_for_model(false, result).expect("sanitized result"); + let got = sanitize_mcp_tool_result_for_model(/*supports_image_input*/ false, result) + .expect("sanitized result"); assert_eq!( got.content, @@ -444,8 +536,11 @@ fn sanitize_mcp_tool_result_for_model_preserves_image_when_supported() { meta: Some(serde_json::json!({"k": "v"})), }; - let got = - sanitize_mcp_tool_result_for_model(true, Ok(original.clone())).expect("unsanitized result"); + let got = sanitize_mcp_tool_result_for_model( + /*supports_image_input*/ true, + Ok(original.clone()), + ) + .expect("unsanitized result"); assert_eq!(got, original); } @@ -545,10 +640,12 @@ fn approval_elicitation_meta_marks_tool_approvals() { assert_eq!( build_mcp_tool_approval_elicitation_meta( "custom_server", - None, - None, - None, - prompt_options(false, false), + /*metadata*/ None, + /*tool_params*/ None, + /*tool_params_display*/ None, + prompt_options( + /*allow_session_remember*/ false, /*allow_persistent_approval*/ false + ), ), Some(serde_json::json!({ MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL, @@ -557,24 +654,29 @@ fn approval_elicitation_meta_marks_tool_approvals() { } #[test] -fn approval_elicitation_meta_keeps_session_persist_behavior_for_custom_servers() { +fn approval_elicitation_meta_merges_session_and_always_persist_for_custom_servers() { assert_eq!( build_mcp_tool_approval_elicitation_meta( "custom_server", Some(&approval_metadata( - None, - None, - None, + /*connector_id*/ None, + /*connector_name*/ None, + /*connector_description*/ None, Some("Run Action"), Some("Runs the selected action."), )), Some(&serde_json::json!({"id": 1})), - None, - prompt_options(true, false), + /*tool_params_display*/ None, + prompt_options( + /*allow_session_remember*/ true, /*allow_persistent_approval*/ true + ), ), Some(serde_json::json!({ MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL, - MCP_TOOL_APPROVAL_PERSIST_KEY: MCP_TOOL_APPROVAL_PERSIST_SESSION, + MCP_TOOL_APPROVAL_PERSIST_KEY: [ + MCP_TOOL_APPROVAL_PERSIST_SESSION, + MCP_TOOL_APPROVAL_PERSIST_ALWAYS, + ], MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: "Run Action", MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: "Runs the selected action.", MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: { @@ -678,11 +780,11 @@ fn prepare_arc_request_action_serializes_mcp_tool_call_shape() { let action = prepare_arc_request_action( &invocation, Some(&approval_metadata( - None, + /*connector_id*/ None, Some("Playwright"), - None, + /*connector_description*/ None, Some("Navigate"), - None, + /*tool_description*/ None, )), ); @@ -732,8 +834,10 @@ fn approval_elicitation_meta_includes_connector_source_for_codex_apps() { Some(&serde_json::json!({ "calendar_id": "primary", })), - None, - prompt_options(false, false), + /*tool_params_display*/ None, + prompt_options( + /*allow_session_remember*/ false, /*allow_persistent_approval*/ false + ), ), Some(serde_json::json!({ MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL, @@ -765,8 +869,10 @@ fn approval_elicitation_meta_merges_session_and_always_persist_with_connector_so Some(&serde_json::json!({ "calendar_id": "primary", })), - None, - prompt_options(true, true), + /*tool_params_display*/ None, + prompt_options( + /*allow_session_remember*/ true, /*allow_persistent_approval*/ true + ), ), Some(serde_json::json!({ MCP_TOOL_APPROVAL_KIND_KEY: MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL, @@ -788,8 +894,8 @@ fn approval_elicitation_meta_merges_session_and_always_persist_with_connector_so } #[tokio::test] -async fn approval_callsite_mode_distinguishes_default_always_allow_and_full_access() { - let (_session, mut turn_context) = make_session_and_context().await; +async fn approval_callsite_mode_distinguishes_default_and_always_allow() { + let (_session, turn_context) = make_session_and_context().await; assert_eq!( mcp_tool_approval_callsite_mode(AppToolApproval::Auto, &turn_context), @@ -803,20 +909,6 @@ async fn approval_callsite_mode_distinguishes_default_always_allow_and_full_acce mcp_tool_approval_callsite_mode(AppToolApproval::Approve, &turn_context), "mcp_tool_call__always_allow" ); - - turn_context - .approval_policy - .set(AskForApproval::Never) - .expect("test setup should allow updating approval policy"); - turn_context - .sandbox_policy - .set(SandboxPolicy::DangerFullAccess) - .expect("test setup should allow updating sandbox policy"); - - assert_eq!( - mcp_tool_approval_callsite_mode(AppToolApproval::Auto, &turn_context), - "mcp_tool_call__full_access" - ); } #[test] @@ -937,6 +1029,41 @@ async fn persist_codex_app_tool_approval_writes_tool_override() { assert!(contents.contains("[apps.calendar.tools.\"calendar/list_events\"]")); } +#[tokio::test] +async fn persist_custom_mcp_tool_approval_writes_tool_override() { + let tmp = tempdir().expect("tempdir"); + std::fs::write( + tmp.path().join(CONFIG_TOML_FILE), + "[mcp_servers.docs]\ncommand = \"docs-server\"\n", + ) + .expect("seed config"); + let config = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .build() + .await + .expect("load config"); + + persist_custom_mcp_tool_approval(&config, "docs", "search") + .await + .expect("persist approval"); + + let contents = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); + let parsed: ConfigToml = toml::from_str(&contents).expect("parse config"); + let tool = parsed + .mcp_servers + .get("docs") + .and_then(|server| server.tools.get("search")) + .expect("docs/search tool config exists"); + + assert_eq!( + tool, + &McpServerToolConfig { + approval_mode: Some(AppToolApproval::Approve), + } + ); + assert!(contents.contains("[mcp_servers.docs.tools.search]")); +} + #[tokio::test] async fn maybe_persist_mcp_tool_approval_reloads_session_config() { let (session, turn_context) = make_session_and_context().await; @@ -976,6 +1103,104 @@ async fn maybe_persist_mcp_tool_approval_reloads_session_config() { assert_eq!(mcp_tool_approval_is_remembered(&session, &key).await, true); } +#[tokio::test] +async fn maybe_persist_mcp_tool_approval_reloads_session_config_for_custom_server() { + let (session, turn_context) = make_session_and_context().await; + let codex_home = session.codex_home().await; + std::fs::create_dir_all(&codex_home).expect("create codex home"); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + "[mcp_servers.docs]\ncommand = \"docs-server\"\n", + ) + .expect("seed config"); + let key = McpToolApprovalKey { + server: "docs".to_string(), + connector_id: None, + tool_name: "search".to_string(), + }; + + maybe_persist_mcp_tool_approval(&session, &turn_context, key.clone()).await; + + let config = session.get_config().await; + let mcp_servers_toml = config + .config_layer_stack + .effective_config() + .as_table() + .and_then(|table| table.get("mcp_servers")) + .cloned() + .expect("mcp_servers table"); + let mcp_servers = HashMap::::deserialize(mcp_servers_toml) + .expect("deserialize MCP servers"); + let tool = mcp_servers + .get("docs") + .and_then(|server| server.tools.get("search")) + .expect("docs/search tool config exists"); + + assert_eq!( + tool, + &McpServerToolConfig { + approval_mode: Some(AppToolApproval::Approve), + } + ); + assert_eq!(mcp_tool_approval_is_remembered(&session, &key).await, true); +} + +#[tokio::test] +async fn maybe_persist_mcp_tool_approval_writes_project_config_for_project_server() { + let (session, mut turn_context) = make_session_and_context().await; + let codex_home = session.codex_home().await; + let project_dir = tempdir().expect("tempdir"); + std::fs::write(project_dir.path().join(".git"), "gitdir: nowhere").expect("seed git marker"); + let project_codex_dir = project_dir.path().join(".codex"); + std::fs::create_dir_all(&project_codex_dir).expect("create project .codex dir"); + std::fs::write( + project_codex_dir.join(CONFIG_TOML_FILE), + "[mcp_servers.docs]\ncommand = \"docs-server\"\n", + ) + .expect("seed project config"); + ConfigEditsBuilder::new(&codex_home) + .set_project_trust_level( + project_dir.path(), + codex_protocol::config_types::TrustLevel::Trusted, + ) + .apply() + .await + .expect("trust project"); + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(project_dir.path().to_path_buf())) + .build() + .await + .expect("load project config"); + turn_context.cwd = config.cwd.clone(); + turn_context.config = Arc::new(config); + let key = McpToolApprovalKey { + server: "docs".to_string(), + connector_id: None, + tool_name: "search".to_string(), + }; + + maybe_persist_mcp_tool_approval(&session, &turn_context, key.clone()).await; + + let contents = std::fs::read_to_string(project_codex_dir.join(CONFIG_TOML_FILE)) + .expect("read project config"); + let parsed: ConfigToml = toml::from_str(&contents).expect("parse project config"); + let tool = parsed + .mcp_servers + .get("docs") + .and_then(|server| server.tools.get("search")) + .expect("docs/search tool config exists"); + + assert_eq!( + tool, + &McpServerToolConfig { + approval_mode: Some(AppToolApproval::Approve), + } + ); + assert!(contents.contains("[mcp_servers.docs.tools.search]")); + assert_eq!(mcp_tool_approval_is_remembered(&session, &key).await, true); +} + #[tokio::test] async fn approve_mode_skips_when_annotations_do_not_require_approval() { let (session, turn_context) = make_session_and_context().await; @@ -987,7 +1212,11 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() { arguments: None, }; let metadata = McpToolApprovalMetadata { - annotations: Some(annotations(Some(true), None, None)), + annotations: Some(annotations( + Some(true), + /*destructive*/ None, + /*open_world*/ None, + )), connector_id: None, connector_name: None, connector_description: None, @@ -1009,6 +1238,124 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() { assert_eq!(decision, None); } +#[tokio::test] +async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() { + use wiremock::Mock; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + let server = start_mock_server().await; + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + + let (mut session, mut turn_context) = make_session_and_context().await; + turn_context + .approval_policy + .set(AskForApproval::OnRequest) + .expect("test setup should allow updating approval policy"); + let mut config = (*turn_context.config).clone(); + config.model_provider.base_url = Some(format!("{}/v1", server.uri())); + config.approvals_reviewer = ApprovalsReviewer::GuardianSubagent; + let config = Arc::new(config); + let models_manager = Arc::new(crate::test_support::models_manager_with_provider( + config.codex_home.clone(), + Arc::clone(&session.services.auth_manager), + config.model_provider.clone(), + )); + session.services.models_manager = models_manager; + turn_context.config = Arc::clone(&config); + turn_context.provider = config.model_provider.clone(); + + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let invocation = McpInvocation { + server: "custom_server".to_string(), + tool: "read_only_tool".to_string(), + arguments: None, + }; + let metadata = McpToolApprovalMetadata { + annotations: Some(annotations( + Some(true), + /*destructive*/ None, + /*open_world*/ None, + )), + connector_id: None, + connector_name: None, + connector_description: None, + tool_title: Some("Read Only Tool".to_string()), + tool_description: None, + codex_apps_meta: None, + }; + + let decision = maybe_request_mcp_tool_approval( + &session, + &turn_context, + "call-guardian", + &invocation, + Some(&metadata), + AppToolApproval::Auto, + ) + .await; + + assert_eq!(decision, None); +} + +#[tokio::test] +async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval() { + let (session, turn_context, _rx_event) = make_session_and_context_with_rx().await; + { + let mut active_turn = session.active_turn.lock().await; + *active_turn = Some(ActiveTurn::default()); + } + let invocation = McpInvocation { + server: "custom_server".to_string(), + tool: "read_only_tool".to_string(), + arguments: None, + }; + let metadata = McpToolApprovalMetadata { + annotations: Some(annotations( + Some(true), + /*destructive*/ None, + /*open_world*/ None, + )), + connector_id: None, + connector_name: None, + connector_description: None, + tool_title: Some("Read Only Tool".to_string()), + tool_description: None, + codex_apps_meta: None, + }; + + let mut approval_task = { + let session = Arc::clone(&session); + let turn_context = Arc::clone(&turn_context); + tokio::spawn(async move { + maybe_request_mcp_tool_approval( + &session, + &turn_context, + "call-prompt", + &invocation, + Some(&metadata), + AppToolApproval::Prompt, + ) + .await + }) + }; + + assert!( + tokio::time::timeout(std::time::Duration::from_millis(200), &mut approval_task) + .await + .is_err(), + "prompt mode should wait for approval instead of auto-allowing" + ); + approval_task.abort(); +} + #[tokio::test] async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() { use wiremock::Mock; @@ -1037,7 +1384,7 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() { let (session, mut turn_context) = make_session_and_context().await; turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth( - crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(), + codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(), )); let mut config = (*turn_context.config).clone(); config.chatgpt_base_url = server.uri(); @@ -1078,6 +1425,75 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() { ); } +#[tokio::test] +async fn custom_approve_mode_blocks_when_arc_returns_interrupt_for_model() { + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/safety/arc")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "outcome": "steer-model", + "short_reason": "needs approval", + "rationale": "high-risk action", + "risk_score": 96, + "risk_level": "critical", + "evidence": [{ + "message": "dangerous_tool", + "why": "high-risk action", + }], + }))) + .expect(1) + .mount(&server) + .await; + + let (session, mut turn_context) = make_session_and_context().await; + turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth( + codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(), + )); + let mut config = (*turn_context.config).clone(); + config.chatgpt_base_url = server.uri(); + turn_context.config = Arc::new(config); + + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let invocation = McpInvocation { + server: "docs".to_string(), + tool: "dangerous_tool".to_string(), + arguments: Some(serde_json::json!({ "id": 1 })), + }; + let metadata = McpToolApprovalMetadata { + annotations: Some(annotations(Some(false), Some(true), Some(true))), + connector_id: None, + connector_name: None, + connector_description: None, + tool_title: Some("Dangerous Tool".to_string()), + tool_description: Some("Performs a risky action.".to_string()), + codex_apps_meta: None, + }; + + let decision = maybe_request_mcp_tool_approval( + &session, + &turn_context, + "call-2-custom", + &invocation, + Some(&metadata), + AppToolApproval::Approve, + ) + .await; + + assert_eq!( + decision, + Some(McpToolApprovalDecision::BlockedBySafetyMonitor( + "Tool call was cancelled because of safety risks: high-risk action".to_string(), + )) + ); +} + #[tokio::test] async fn approve_mode_blocks_when_arc_returns_interrupt_without_annotations() { use wiremock::Mock; @@ -1106,7 +1522,7 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_without_annotations() { let (session, mut turn_context) = make_session_and_context().await; turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth( - crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(), + codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(), )); let mut config = (*turn_context.config).clone(); config.chatgpt_base_url = server.uri(); @@ -1148,7 +1564,7 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_without_annotations() { } #[tokio::test] -async fn full_access_auto_mode_blocks_when_arc_returns_interrupt_for_model() { +async fn full_access_mode_skips_arc_monitor_for_all_approval_modes() { use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; @@ -1169,13 +1585,13 @@ async fn full_access_auto_mode_blocks_when_arc_returns_interrupt_for_model() { "why": "high-risk action", }], }))) - .expect(1) + .expect(0) .mount(&server) .await; let (session, mut turn_context) = make_session_and_context().await; turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth( - crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(), + codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(), )); turn_context .approval_policy @@ -1206,22 +1622,23 @@ async fn full_access_auto_mode_blocks_when_arc_returns_interrupt_for_model() { codex_apps_meta: None, }; - let decision = maybe_request_mcp_tool_approval( - &session, - &turn_context, - "call-2", - &invocation, - Some(&metadata), + for approval_mode in [ AppToolApproval::Auto, - ) - .await; + AppToolApproval::Prompt, + AppToolApproval::Approve, + ] { + let decision = maybe_request_mcp_tool_approval( + &session, + &turn_context, + "call-2", + &invocation, + Some(&metadata), + approval_mode, + ) + .await; - assert_eq!( - decision, - Some(McpToolApprovalDecision::BlockedBySafetyMonitor( - "Tool call was cancelled because of safety risks: high-risk action".to_string(), - )) - ); + assert_eq!(decision, None); + } } #[tokio::test] @@ -1272,7 +1689,7 @@ async fn approve_mode_routes_arc_ask_user_to_guardian_when_guardian_reviewer_is_ let (mut session, mut turn_context) = make_session_and_context().await; turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth( - crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(), + codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(), )); turn_context .approval_policy diff --git a/codex-rs/core/src/memories/mod.rs b/codex-rs/core/src/memories/mod.rs index 2a7eef4804..ac49c640ca 100644 --- a/codex-rs/core/src/memories/mod.rs +++ b/codex-rs/core/src/memories/mod.rs @@ -32,7 +32,7 @@ mod artifacts { /// Phase 1 (startup extraction). mod phase_one { /// Default model used for phase 1. - pub(super) const MODEL: &str = "gpt-5.1-codex-mini"; + pub(super) const MODEL: &str = "gpt-5.4-mini"; /// Default reasoning effort used for phase 1. pub(super) const REASONING_EFFORT: super::ReasoningEffort = super::ReasoningEffort::Low; /// Prompt used for phase 1. diff --git a/codex-rs/core/src/memories/phase1.rs b/codex-rs/core/src/memories/phase1.rs index 921bc9953c..0b8cdcdad5 100644 --- a/codex-rs/core/src/memories/phase1.rs +++ b/codex-rs/core/src/memories/phase1.rs @@ -3,9 +3,7 @@ use crate::RolloutRecorder; use crate::codex::Session; use crate::codex::TurnContext; use crate::config::Config; -use crate::config::types::MemoriesConfig; use crate::contextual_user_message::is_memory_excluded_contextual_user_fragment; -use crate::error::CodexErr; use crate::memories::metrics; use crate::memories::phase_one; use crate::memories::phase_one::PRUNE_BATCH_SIZE; @@ -13,9 +11,11 @@ use crate::memories::prompts::build_stage_one_input_message; use crate::rollout::INTERACTIVE_SESSION_SOURCES; use crate::rollout::policy::should_persist_response_item_for_memories; use codex_api::ResponseEvent; +use codex_config::types::MemoriesConfig; use codex_otel::SessionTelemetry; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::config_types::ServiceTier; +use codex_protocol::error::CodexErr; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -228,7 +228,7 @@ async fn build_request_context(session: &Arc, config: &Config) -> Reque let model = session .services .models_manager - .get_model_info(&model_name, config) + .get_model_info(&model_name, &config.to_models_manager_config()) .await; let turn_context = session.new_default_turn().await; RequestContext::from_turn_context( @@ -336,9 +336,9 @@ mod job { }], tools: Vec::new(), parallel_tool_calls: false, - base_instructions: BaseInstructions { + base_instructions: Some(BaseInstructions { text: phase_one::PROMPT.to_string(), - }, + }), personality: None, output_schema: Some(output_schema()), }; @@ -466,7 +466,7 @@ mod job { /// Serializes filtered stage-1 memory items for prompt inclusion. pub(super) fn serialize_filtered_rollout_response_items( items: &[RolloutItem], - ) -> crate::error::Result { + ) -> codex_protocol::error::Result { let filtered = items .iter() .filter_map(|item| { diff --git a/codex-rs/core/src/memories/phase2.rs b/codex-rs/core/src/memories/phase2.rs index 321b314abb..fde135d5e2 100644 --- a/codex-rs/core/src/memories/phase2.rs +++ b/codex-rs/core/src/memories/phase2.rs @@ -1,6 +1,7 @@ use crate::agent::AgentStatus; use crate::agent::status::is_final as is_final_agent_status; use crate::codex::Session; +use crate::codex::emit_subagent_session_started; use crate::config::Config; use crate::memories::memory_root; use crate::memories::metrics; @@ -132,7 +133,7 @@ pub(super) async fn run(session: &Arc, config: Arc) { let thread_id = match session .services .agent_control - .spawn_agent(agent_config, prompt, Some(source)) + .spawn_agent(agent_config, prompt.into(), Some(source)) .await { Ok(thread_id) => thread_id, @@ -143,6 +144,26 @@ pub(super) async fn run(session: &Arc, config: Arc) { } }; + if let Some(thread_config) = session + .services + .agent_control + .get_agent_config_snapshot(thread_id) + .await + { + if session.enabled(Feature::GeneralAnalytics) { + let client_metadata = session.app_server_client_metadata().await; + emit_subagent_session_started( + &session.services.analytics_events_client, + client_metadata, + thread_id, + thread_config, + SubAgentSource::MemoryConsolidation, + ); + } + } else { + warn!("failed to load memory consolidation thread config for analytics: {thread_id}"); + } + // 6. Spawn the agent handler. agent::handle( session, diff --git a/codex-rs/core/src/memories/prompts.rs b/codex-rs/core/src/memories/prompts.rs index 851179426f..56cb2b7b60 100644 --- a/codex-rs/core/src/memories/prompts.rs +++ b/codex-rs/core/src/memories/prompts.rs @@ -1,37 +1,42 @@ use crate::memories::memory_root; use crate::memories::phase_one; use crate::memories::storage::rollout_summary_file_stem_from_parts; -use askama::Template; use codex_protocol::openai_models::ModelInfo; use codex_state::Phase2InputSelection; use codex_state::Stage1Output; use codex_state::Stage1OutputRef; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::truncate_text; +use codex_utils_template::Template; use std::path::Path; +use std::sync::LazyLock; use tokio::fs; use tracing::warn; -#[derive(Template)] -#[template(path = "memories/consolidation.md", escape = "none")] -struct ConsolidationPromptTemplate<'a> { - memory_root: &'a str, - phase2_input_selection: &'a str, -} +static CONSOLIDATION_PROMPT_TEMPLATE: LazyLock