diff --git a/.bazelrc b/.bazelrc index b9275426fa..fd6ff75beb 100644 --- a/.bazelrc +++ b/.bazelrc @@ -76,6 +76,44 @@ common:ci-bazel --build_metadata=TAG_workflow=bazel 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 diff --git a/.codex/skills/babysit-pr/SKILL.md b/.codex/skills/babysit-pr/SKILL.md index 303bb030fa..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,7 +24,7 @@ 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. @@ -33,10 +33,10 @@ Accept any of the following: 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, verify mergeability / merge-conflict status (for example via `gh pr view`) in addition to CI and review state. +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 the PR is green + review-clean + mergeable, `stop_pr_closed` appears, or a user-help-required blocker is reached. +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 @@ -131,7 +131,7 @@ Use this loop in a live Codex session: 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 success and stop. +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. @@ -141,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: @@ -162,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 index 34bbd40b19..a1e46d2d04 100644 --- a/.github/actions/setup-bazel-ci/action.yml +++ b/.github/actions/setup-bazel-ci/action.yml @@ -60,8 +60,72 @@ runs: # 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. - $bazelOutputUserRoot = if (Test-Path 'D:\') { 'D:\b' } else { 'C:\b' } + $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 + if (-not $hasDDrive) { + $repositoryCache = Join-Path $env:USERPROFILE '.cache\bazel-repo-cache' + "BAZEL_REPOSITORY_CACHE=$repositoryCache" | 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' 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 index cf50135c2e..d08b32e3d4 100755 --- a/.github/scripts/run-bazel-ci.sh +++ b/.github/scripts/run-bazel-ci.sh @@ -41,6 +41,15 @@ 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) @@ -60,7 +69,7 @@ print_bazel_test_log_tails() { bazel_info_cmd+=("${bazel_startup_args[@]}") fi - testlogs_dir="$("${bazel_info_cmd[@]}" info bazel-testlogs 2>/dev/null || echo bazel-testlogs)" + 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 @@ -126,6 +135,41 @@ if [[ $remote_download_toplevel -eq 1 ]]; then 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 [[ "${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 @@ -149,7 +193,7 @@ if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then bazel_run_args+=("${post_config_bazel_args[@]}") fi set +e - "${bazel_cmd[@]}" \ + run_bazel "${bazel_cmd[@]:1}" \ --noexperimental_remote_repo_contents_cache \ "${bazel_run_args[@]}" \ -- \ @@ -184,7 +228,7 @@ else bazel_run_args+=("${post_config_bazel_args[@]}") fi set +e - "${bazel_cmd[@]}" \ + run_bazel "${bazel_cmd[@]:1}" \ --noexperimental_remote_repo_contents_cache \ "${bazel_run_args[@]}" \ -- \ 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/ci.yml b/.github/workflows/ci.yml index 6c53900401..76a8aa0148 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,12 @@ jobs: - name: Checkout repository 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@a8198c4bff370c8506180b035930dea56dbd5288 # v5 with: diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index a09c7fd433..146e57524c 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -99,23 +99,13 @@ jobs: run: | sudo DEBIAN_FRONTEND=noninteractive apt-get update sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - - name: Install nightly argument-comment-lint toolchain - if: ${{ runner.os == 'Windows' }} - 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: 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 \ @@ -123,11 +113,18 @@ jobs: --keep_going \ --build_metadata=COMMIT_SHA=${GITHUB_SHA} \ -- \ - //codex-rs/... - - name: Run argument comment lint on codex-rs via packaged wrapper + ${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: python3 ./tools/argument-comment-lint/run-prebuilt-linter.py + 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: @@ -448,10 +445,10 @@ jobs: 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 + cargo chef cook --recipe-path "$RECIPE" --target ${{ matrix.target }} --release - name: cargo clippy - run: cargo clippy --target ${{ matrix.target }} --all-features --tests --profile ${{ matrix.profile }} --timings -- -D warnings + run: cargo clippy --target ${{ matrix.target }} --tests --profile ${{ matrix.profile }} --timings -- -D warnings - name: Upload Cargo timings (clippy) if: always() @@ -675,7 +672,7 @@ jobs: - name: tests id: test - run: cargo nextest run --all-features --no-fail-fast --target ${{ matrix.target }} --cargo-profile ci-test --timings + run: cargo nextest run --no-fail-fast --target ${{ matrix.target }} --cargo-profile ci-test --timings env: RUST_BACKTRACE: 1 NEXTEST_STATUS_LEVEL: leak diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index a655b3071e..3a9eadc8be 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -159,23 +159,13 @@ jobs: run: | sudo DEBIAN_FRONTEND=noninteractive apt-get update sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - - name: Install nightly argument-comment-lint toolchain - if: ${{ runner.os == 'Windows' }} - 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: 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 \ @@ -183,11 +173,18 @@ jobs: --keep_going \ --build_metadata=COMMIT_SHA=${GITHUB_SHA} \ -- \ - //codex-rs/... - - name: Run argument comment lint on codex-rs via packaged wrapper + ${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: python3 ./tools/argument-comment-lint/run-prebuilt-linter.py + run: | + ./.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: diff --git a/AGENTS.md b/AGENTS.md index ff36b0e19c..c8d989fe9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,8 +15,10 @@ 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. - If you change `ConfigToml` or nested config types, run `just write-config-schema` to update `codex-rs/core/config.schema.json`. @@ -49,8 +51,6 @@ 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`. 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 e71f92ca77..d1a32df5f3 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -82,6 +82,13 @@ rules_rust = use_extension("@rules_rs//rs/experimental:rules_rust.bzl", "rules_r 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_msvc_direct_link_args.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, ) @@ -96,6 +103,35 @@ nightly_rust.toolchain( 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") @@ -163,6 +199,8 @@ 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", ], ) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 3dd569c6b0..394efe7c16 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1337,10 +1337,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", @@ -1719,6 +1721,7 @@ dependencies = [ "clap", "codex-client", "codex-cloud-tasks-client", + "codex-cloud-tasks-mock-client", "codex-core", "codex-git-utils", "codex-login", @@ -1748,12 +1751,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" @@ -1836,6 +1848,7 @@ dependencies = [ "codex-hooks", "codex-instructions", "codex-login", + "codex-mcp", "codex-network-proxy", "codex-otel", "codex-plugin", @@ -2248,6 +2261,35 @@ 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" @@ -2613,12 +2655,17 @@ dependencies = [ name = "codex-tools" version = "0.0.0" dependencies = [ + "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]] @@ -2832,6 +2879,7 @@ dependencies = [ name = "codex-utils-plugins" version = "0.0.0" dependencies = [ + "codex-login", "serde", "serde_json", "tempfile", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index d5a4baa4d5..2d80bbc69e 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -18,6 +18,7 @@ members = [ "cloud-requirements", "cloud-tasks", "cloud-tasks-client", + "cloud-tasks-mock-client", "cli", "connectors", "config", @@ -38,6 +39,7 @@ members = [ "linux-sandbox", "lmstudio", "login", + "codex-mcp", "mcp-server", "network-proxy", "ollama", @@ -96,10 +98,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-code-mode = { path = "code-mode" } codex-app-server = { path = "app-server" } codex-app-server-client = { path = "app-server-client" } codex-app-server-protocol = { path = "app-server-protocol" } @@ -112,16 +113,19 @@ codex-chatgpt = { path = "chatgpt" } codex-cli = { path = "cli" } codex-client = { path = "codex-client" } 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" } @@ -130,6 +134,7 @@ 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-network-proxy = { path = "network-proxy" } codex-ollama = { path = "ollama" } @@ -137,9 +142,9 @@ 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-rmcp-client = { path = "rmcp-client" } +codex-rollout = { path = "rollout" } codex-sandboxing = { path = "sandboxing" } codex-secrets = { path = "secrets" } codex-shell-command = { path = "shell-command" } @@ -150,7 +155,6 @@ codex-stdio-to-uds = { path = "stdio-to-uds" } codex-terminal-detection = { path = "terminal-detection" } codex-tools = { path = "tools" } codex-tui = { path = "tui" } -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" } @@ -171,8 +175,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" } @@ -217,12 +222,12 @@ 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" @@ -264,7 +269,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", @@ -333,6 +337,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/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 4f49a7c713..3a98fbb837 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -1,16 +1,47 @@ -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::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::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; @@ -24,6 +55,61 @@ 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(), + 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()) @@ -194,6 +280,171 @@ 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 plugin_used_event_serializes_expected_shape() { let tracking = TrackEventsContext { @@ -280,6 +531,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..b8a543a3b2 --- /dev/null +++ b/codex-rs/analytics/src/client.rs @@ -0,0 +1,272 @@ +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::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_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..36efb01a69 --- /dev/null +++ b/codex-rs/analytics/src/events.rs @@ -0,0 +1,230 @@ +use crate::facts::AppInvocation; +use crate::facts::InvocationType; +use crate::facts::PluginState; +use crate::facts::TrackEventsContext; +use codex_login::default_client::originator; +use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::protocol::SessionSource; +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(), + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs new file mode 100644 index 0000000000..31b8516e85 --- /dev/null +++ b/codex-rs/analytics/src/facts.rs @@ -0,0 +1,116 @@ +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 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, +} + +#[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 { + 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..6f927d09c0 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -1,8 +1,15 @@ -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::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..d83cddcbbc --- /dev/null +++ b/codex-rs/analytics/src/reducer.rs @@ -0,0 +1,305 @@ +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::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 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::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, + }, + ); + } + + 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-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index e1a7cd3f3d..fc36adaa55 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -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": [ @@ -1400,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" }, @@ -1415,6 +1594,7 @@ } }, "required": [ + "action", "review", "targetItemId", "threadId", @@ -1425,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" }, @@ -1440,6 +1622,7 @@ } }, "required": [ + "action", "review", "targetItemId", "threadId", @@ -1672,6 +1855,15 @@ ], "type": "object" }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5Tcp", + "socks5Udp" + ], + "type": "string" + }, "NonSteerableTurnKind": { "enum": [ "review", 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 084da013e4..444c8af7c2 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 @@ -3063,7 +3063,7 @@ "type": "string" }, "protocol": { - "$ref": "#/definitions/NetworkApprovalProtocol" + "$ref": "#/definitions/v2/NetworkApprovalProtocol" } }, "required": [ @@ -3072,15 +3072,6 @@ ], "type": "object" }, - "NetworkApprovalProtocol": { - "enum": [ - "http", - "https", - "socks5Tcp", - "socks5Udp" - ], - "type": "string" - }, "NetworkPolicyAmendment": { "properties": { "action": { @@ -8077,6 +8068,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": [ @@ -8087,6 +8248,13 @@ ], "type": "string" }, + "GuardianCommandSource": { + "enum": [ + "shell", + "unifiedExec" + ], + "type": "string" + }, "GuardianRiskLevel": { "description": "[UNSTABLE] Risk level assigned by guardian approval review.", "enum": [ @@ -8349,7 +8517,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" }, @@ -8364,6 +8534,7 @@ } }, "required": [ + "action", "review", "targetItemId", "threadId", @@ -8376,7 +8547,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" }, @@ -8391,6 +8564,7 @@ } }, "required": [ + "action", "review", "targetItemId", "threadId", @@ -9249,6 +9423,15 @@ ], "type": "string" }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5Tcp", + "socks5Udp" + ], + "type": "string" + }, "NetworkDomainPermission": { "enum": [ "allow", 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 59dab32b19..e604157ad6 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 @@ -4847,6 +4847,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 +5027,13 @@ ], "type": "string" }, + "GuardianCommandSource": { + "enum": [ + "shell", + "unifiedExec" + ], + "type": "string" + }, "GuardianRiskLevel": { "description": "[UNSTABLE] Risk level assigned by guardian approval review.", "enum": [ @@ -5163,7 +5340,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" }, @@ -5178,6 +5357,7 @@ } }, "required": [ + "action", "review", "targetItemId", "threadId", @@ -5190,7 +5370,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" }, @@ -5205,6 +5387,7 @@ } }, "required": [ + "action", "review", "targetItemId", "threadId", @@ -6063,6 +6246,15 @@ ], "type": "string" }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5Tcp", + "socks5Udp" + ], + "type": "string" + }, "NetworkDomainPermission": { "enum": [ "allow", 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/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/v2/GuardianCommandSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GuardianCommandSource.ts new file mode 100644 index 0000000000..b48e9b0826 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GuardianCommandSource.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 GuardianCommandSource = "shell" | "unifiedExec"; 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/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index d0687e5f1d..495572fd78 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -123,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"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 9373c852d8..a6a4aa5403 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -8,6 +8,8 @@ use codex_experimental_api_macros::ExperimentalApi; use codex_protocol::account::PlanType; use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest; 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; @@ -4445,14 +4447,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")] @@ -4933,7 +5158,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)] @@ -4950,7 +5175,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)] @@ -7487,26 +7712,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!({ @@ -7527,6 +7732,31 @@ 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!({ diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 4365cb8e29..2f244909f1 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -913,10 +913,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: diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 9a850c0b1e..78c60b0aee 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -223,6 +223,7 @@ fn guardian_auto_approval_review_notification( 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( @@ -231,7 +232,7 @@ fn guardian_auto_approval_review_notification( turn_id, target_item_id: assessment.id.clone(), review, - action: assessment.action.clone(), + action, }, ) } @@ -244,7 +245,7 @@ fn guardian_auto_approval_review_notification( turn_id, target_item_id: assessment.id.clone(), review, - action: assessment.action.clone(), + action, }, ) } @@ -2879,7 +2880,6 @@ mod tests { use pretty_assertions::assert_eq; use rmcp::model::Content; use serde_json::Value as JsonValue; - use serde_json::json; use std::time::Duration; use tokio::sync::Mutex; use tokio::sync::mpsc; @@ -2904,10 +2904,11 @@ mod tests { #[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", @@ -2918,7 +2919,7 @@ mod tests { risk_score: None, risk_level: None, rationale: None, - action: Some(action.clone()), + action: action.clone(), }, ); @@ -2934,7 +2935,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:?}"), } @@ -2943,10 +2944,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", @@ -2957,7 +2959,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(), }, ); @@ -2973,7 +2975,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:?}"), } @@ -2982,10 +2984,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", @@ -2996,7 +3000,7 @@ mod tests { risk_score: None, risk_level: None, rationale: None, - action: Some(action.clone()), + action: action.clone(), }, ); @@ -3009,7 +3013,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:?}"), } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 2798dcb92e..ac371e1a64 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -32,6 +32,7 @@ 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; @@ -179,6 +180,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::AnalyticsEventsClient; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::CodexThread; @@ -403,6 +405,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>>, @@ -433,6 +436,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, @@ -455,6 +460,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>>, @@ -519,6 +525,7 @@ impl CodexMessageProcessor { auth_manager, thread_manager, outgoing, + analytics_events_client, arg0_paths, config, cli_overrides, @@ -531,6 +538,7 @@ impl CodexMessageProcessor { auth_manager, thread_manager, outgoing: outgoing.clone(), + analytics_events_client, arg0_paths, config, cli_overrides, @@ -1015,7 +1023,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()); } @@ -1094,7 +1102,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()); } @@ -1531,7 +1539,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 { @@ -2086,6 +2094,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(), @@ -2318,6 +2328,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 @@ -3796,6 +3817,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; } @@ -4403,6 +4433,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; @@ -5000,9 +5039,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; }); } @@ -5011,8 +5053,15 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: ListMcpServerStatusParams, config: Config, + mcp_config: codex_core::mcp::McpConfig, + auth: Option, ) { - let snapshot = collect_mcp_snapshot(&config).await; + let snapshot = collect_mcp_snapshot( + &mcp_config, + auth.as_ref(), + request_id.request_id.to_string(), + ) + .await; let tools_by_server = group_tools_by_server(&snapshot.tools); @@ -6984,6 +7033,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(), @@ -7071,6 +7122,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(), @@ -7102,6 +7155,8 @@ impl CodexMessageProcessor { outgoing, thread_manager, thread_state_manager, + analytics_events_client: _, + general_analytics_enabled: _, thread_watch_manager, fallback_model_provider, codex_home, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 6b96bb4e38..4f79b1445a 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -74,6 +74,7 @@ use codex_app_server_protocol::Result; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_arg0::Arg0DispatchPaths; +use codex_core::AppServerRpcTransport; use codex_core::config::Config; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::LoaderOverrides; @@ -393,6 +394,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { config_warnings: args.config_warnings, session_source: args.session_source, enable_codex_api_key_env: args.enable_codex_api_key_env, + rpc_transport: AppServerRpcTransport::InProcess, }); let mut thread_created_rx = processor.thread_created_receiver(); let mut session = ConnectionSessionState::default(); diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 3ecefa83fa..d2bae7ccd7 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -36,6 +36,7 @@ use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::TextPosition as AppTextPosition; use codex_app_server_protocol::TextRange as AppTextRange; +use codex_core::AppServerRpcTransport; use codex_core::ExecPolicyError; use codex_core::check_execpolicy_for_warnings; use codex_core::config_loader::ConfigLoadError; @@ -623,6 +624,7 @@ pub async fn run_main_with_transport( config_warnings, session_source, enable_codex_api_key_env: false, + rpc_transport: analytics_rpc_transport(transport), }); let mut thread_created_rx = processor.thread_created_receiver(); let mut running_turn_count_rx = processor.subscribe_running_assistant_turn_count(); @@ -846,6 +848,13 @@ pub async fn run_main_with_transport( Ok(()) } +fn analytics_rpc_transport(transport: AppServerTransport) -> AppServerRpcTransport { + match transport { + AppServerTransport::Stdio => AppServerRpcTransport::Stdio, + AppServerTransport::WebSocket { .. } => AppServerRpcTransport::Websocket, + } +} + #[cfg(test)] mod tests { use super::LogFormat; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 7f0e1d18f8..47be1e1e59 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -56,6 +56,7 @@ use codex_app_server_protocol::experimental_required_message; use codex_arg0::Arg0DispatchPaths; use codex_chatgpt::connectors; use codex_core::AnalyticsEventsClient; +use codex_core::AppServerRpcTransport; use codex_core::AuthManager; use codex_core::ThreadManager; use codex_core::config::Config; @@ -70,9 +71,10 @@ use codex_core::models_manager::collaboration_mode_presets::CollaborationModesCo use codex_exec_server::EnvironmentManager; use codex_features::Feature; use codex_feedback::CodexFeedback; +use codex_login::AuthMode as LoginAuthMode; +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_protocol::ThreadId; use codex_protocol::protocol::SessionSource; @@ -102,7 +104,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, @@ -144,11 +150,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, + )) } } @@ -159,9 +165,11 @@ 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, } #[derive(Clone, Debug, Default)] @@ -186,6 +194,7 @@ pub(crate) struct MessageProcessorArgs { pub(crate) config_warnings: Vec, pub(crate) session_source: SessionSource, pub(crate) enable_codex_api_key_env: bool, + pub(crate) rpc_transport: AppServerRpcTransport, } impl MessageProcessor { @@ -205,11 +214,15 @@ impl MessageProcessor { config_warnings, session_source, enable_codex_api_key_env, + rpc_transport, } = args; - let auth_manager = AuthManager::shared( + let auth_manager = AuthManager::shared_with_external_auth( config.codex_home.clone(), enable_codex_api_key_env, config.cli_auth_credentials_store_mode, + Arc::new(ExternalAuthRefreshBridge { + outgoing: outgoing.clone(), + }), ); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), @@ -223,9 +236,6 @@ impl MessageProcessor { 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(), @@ -242,6 +252,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(), @@ -262,7 +273,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(); @@ -275,14 +286,16 @@ 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, } } 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( @@ -547,6 +560,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) => ( @@ -568,7 +582,7 @@ impl MessageProcessor { session.app_server_client_name = Some(name.clone()); session.client_version = Some(version.clone()); let originator = name.clone(); - if let Err(error) = set_default_originator(originator) { + if let Err(error) = set_default_originator(originator.clone()) { match error { SetOriginatorError::InvalidHeaderValue => { let error = JSONRPCErrorError { @@ -591,6 +605,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() { 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 f52b6affe6..44cbe21b60 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -20,6 +20,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput; use codex_arg0::Arg0DispatchPaths; +use codex_core::AppServerRpcTransport; use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config_loader::CloudRequirementsLoader; @@ -246,6 +247,7 @@ fn build_test_processor( config_warnings: Vec::new(), session_source: SessionSource::VSCode, enable_codex_api_key_env: false, + rpc_transport: AppServerRpcTransport::Stdio, }); (processor, outgoing_rx) } diff --git a/codex-rs/app-server/tests/common/Cargo.toml b/codex-rs/app-server/tests/common/Cargo.toml index 4eef03e969..c71e5797ad 100644 --- a/codex-rs/app-server/tests/common/Cargo.toml +++ b/codex-rs/app-server/tests/common/Cargo.toml @@ -7,6 +7,9 @@ license.workspace = true [lib] path = "lib.rs" +[lints] +workspace = true + [dependencies] anyhow = { workspace = true } base64 = { workspace = true } diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 427f6cc1f2..8072ff45f6 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -27,7 +27,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, 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 5a34fa4d39..1520d99e3e 100644 --- a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs +++ b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs @@ -366,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/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index a389ffbe1a..bd9276ddcd 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_core::auth::AuthCredentialsStoreMode; 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"; @@ -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/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index e6be4e5a7c..dc68992fe4 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -38,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] @@ -177,6 +182,50 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { 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 +298,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(), @@ -509,7 +559,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 { + "\n[features]\ngeneral_analytics = true\n".to_string() + } else { + String::new() + }; let config_toml = codex_home.join("config.toml"); std::fs::write( config_toml, @@ -521,6 +577,7 @@ sandbox_mode = "read-only" chatgpt_base_url = "{chatgpt_base_url}" model_provider = "mock_provider" +{general_analytics_toml} [model_providers.mock_provider] name = "Mock provider for test" 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 564de50d3a..3a93451326 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -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; @@ -1449,6 +1499,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(), @@ -1827,6 +1878,7 @@ model_provider = "mock_provider" [features] personality = true +general_analytics = true [model_providers.mock_provider] name = "Mock provider for test" @@ -1843,7 +1895,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, @@ -1858,6 +1916,7 @@ model_provider = "mock_provider" [features] personality = true +{general_analytics_toml} [model_providers.mock_provider] name = "Mock provider for test" 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..c684bd2336 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -26,6 +26,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 +35,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] @@ -153,6 +159,73 @@ 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; @@ -455,6 +528,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(), @@ -538,7 +612,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 { + "\n[features]\ngeneral_analytics = true\n".to_string() + } else { + String::new() + }; let config_toml = codex_home.join("config.toml"); std::fs::write( config_toml, @@ -550,6 +630,7 @@ sandbox_mode = "read-only" chatgpt_base_url = "{chatgpt_base_url}" model_provider = "mock_provider" +{general_analytics_toml} [model_providers.mock_provider] name = "Mock provider for test" diff --git a/codex-rs/backend-client/Cargo.toml b/codex-rs/backend-client/Cargo.toml index 8279dba630..96e9d73599 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"] } diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 2559b2b39e..4ef0fb8462 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -309,7 +309,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/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 7587b341b9..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 = { 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/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index f11eca38f5..f7698a300f 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -40,6 +40,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,9 +50,10 @@ 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, }); } @@ -2132,10 +2134,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; diff --git a/codex-rs/cloud-tasks/tests/env_filter.rs b/codex-rs/cloud-tasks/tests/env_filter.rs index 89bd2fc71c..716e4f05ed 100644 --- a/codex-rs/cloud-tasks/tests/env_filter.rs +++ b/codex-rs/cloud-tasks/tests/env_filter.rs @@ -1,5 +1,5 @@ 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() { diff --git a/codex-rs/code-mode/src/description.rs b/codex-rs/code-mode/src/description.rs index 22639b3246..3a87fef092 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). 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-mcp/BUILD.bazel b/codex-rs/codex-mcp/BUILD.bazel new file mode 100644 index 0000000000..fbae63201a --- /dev/null +++ b/codex-rs/codex-mcp/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + 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..149578c967 --- /dev/null +++ b/codex-rs/codex-mcp/src/lib.rs @@ -0,0 +1,2 @@ +pub mod mcp; +pub mod mcp_connection_manager; diff --git a/codex-rs/core/src/mcp/auth.rs b/codex-rs/codex-mcp/src/mcp/auth.rs similarity index 98% rename from codex-rs/core/src/mcp/auth.rs rename to codex-rs/codex-mcp/src/mcp/auth.rs index 6b146f129b..a4e14e42f9 100644 --- a/codex-rs/core/src/mcp/auth.rs +++ b/codex-rs/codex-mcp/src/mcp/auth.rs @@ -9,8 +9,8 @@ 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 { diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs similarity index 76% rename from codex-rs/core/src/mcp/mod.rs rename to codex-rs/codex-mcp/src/mcp/mod.rs index afeb5647ea..b843bbadc0 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -1,36 +1,37 @@ pub mod auth; mod skill_dependencies; -pub(crate) use skill_dependencies::maybe_prompt_and_install_mcp_dependencies; +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_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 codex_rmcp_client::OAuthCredentialsStoreMode; 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"; /// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`. @@ -59,6 +60,46 @@ pub fn qualified_mcp_tool_name_prefix(server_name: &str) -> String { )) } +/// 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>, @@ -173,11 +214,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 @@ -206,13 +247,12 @@ fn codex_apps_mcp_server_config(config: &Config, auth: Option<&CodexAuth>) -> Mc } } -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), @@ -223,69 +263,29 @@ 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 { + 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(), @@ -306,18 +306,19 @@ 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; @@ -359,7 +360,7 @@ 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, ) -> McpListToolsResponseEvent { @@ -372,7 +373,7 @@ pub(crate) async fn collect_mcp_snapshot_from_manager( let auth_statuses = auth_status_entries .iter() .map(|(name, entry)| (name.clone(), entry.auth_status)) - .collect(); + .collect::>(); let tools = tools .into_iter() @@ -389,7 +390,7 @@ pub(crate) async fn collect_mcp_snapshot_from_manager( None } }) - .collect(); + .collect::>(); let resources = resources .into_iter() @@ -424,7 +425,7 @@ pub(crate) async fn collect_mcp_snapshot_from_manager( .collect::>(); (name, resources) }) - .collect(); + .collect::>(); let resource_templates = resource_templates .into_iter() @@ -460,7 +461,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 67% rename from codex-rs/core/src/mcp/mod_tests.rs rename to codex-rs/codex-mcp/src/mcp/mod_tests.rs index 6821fbce32..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 { @@ -153,8 +147,7 @@ fn codex_apps_mcp_url_for_base_url_keeps_existing_paths() { #[test] fn codex_apps_mcp_url_uses_legacy_codex_apps_path() { - let mut config = crate::config::test_config(); - config.chatgpt_base_url = "https://chatgpt.com".to_string(); + let config = test_mcp_config(PathBuf::from("/tmp")); assert_eq!( codex_apps_mcp_url(&config), @@ -164,25 +157,15 @@ fn codex_apps_mcp_url_uses_legacy_codex_apps_path() { #[test] fn codex_apps_server_config_uses_legacy_codex_apps_path() { - let mut config = crate::config::test_config(); - config.chatgpt_base_url = "https://chatgpt.com".to_string(); + 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(), - /*connectors_enabled*/ false, - /*auth*/ 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, /*connectors_enabled*/ true, /*auth*/ 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"); @@ -195,44 +178,13 @@ 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 { @@ -253,16 +205,37 @@ async fn effective_mcp_servers_include_plugins_without_overriding_user_config() tools: HashMap::new(), }, ); - config - .mcp_servers - .set(configured_servers) - .expect("test config should accept MCP servers"); + 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(), + }, + ); - let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone()))); - let effective = mcp_manager.effective_servers(&config, /*auth*/ 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, .. } => { @@ -276,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 95% rename from codex-rs/core/src/mcp/skill_dependencies_tests.rs rename to codex-rs/codex-mcp/src/mcp/skill_dependencies_tests.rs index 7387ef4b31..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,9 +12,9 @@ fn skill_with_tools(tools: Vec) -> SkillMetadata { short_description: None, interface: None, dependencies: Some(SkillDependencies { tools }), - policy: None, - path_to_skills_md: PathBuf::from("skill"), + path: PathBuf::from("skill"), scope: SkillScope::User, + enabled: true, } } 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 b03f1a7a12..9a995a5c76 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/codex-mcp/src/mcp_connection_manager.rs @@ -20,9 +20,13 @@ use std::time::Duration; use std::time::Instant; 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; @@ -79,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. @@ -94,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); @@ -112,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() @@ -180,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, @@ -576,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(), @@ -591,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) } @@ -618,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, @@ -630,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) { @@ -637,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(), @@ -664,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 { @@ -695,6 +710,7 @@ impl McpConnectionManager { }; let _ = emit_update( + submit_id.as_str(), &tx_event, McpStartupUpdateEvent { server: server_name.clone(), @@ -728,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; @@ -756,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; }; @@ -767,7 +783,7 @@ impl McpConnectionManager { } } - pub(crate) async fn required_startup_failures( + pub async fn required_startup_failures( &self, required_servers: &[String], ) -> Vec { @@ -1113,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 @@ -1166,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 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 99% rename from codex-rs/core/src/mcp_connection_manager_tests.rs rename to codex-rs/codex-mcp/src/mcp_connection_manager_tests.rs index ec3b58678d..b34a5cc043 100644 --- a/codex-rs/core/src/mcp_connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/mcp_connection_manager_tests.rs @@ -612,7 +612,7 @@ fn mcp_init_error_display_includes_startup_timeout_hint() { 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/config/src/lib.rs b/codex-rs/config/src/lib.rs index 9492d56b4d..f769f2496c 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -3,6 +3,8 @@ mod config_requirements; mod constraint; mod diagnostics; mod fingerprint; +mod mcp_edit; +mod mcp_types; mod merge; mod overrides; mod project_root_markers; @@ -50,6 +52,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/config/src/mcp_types_tests.rs b/codex-rs/config/src/mcp_types_tests.rs new file mode 100644 index 0000000000..694314e6e2 --- /dev/null +++ b/codex-rs/config/src/mcp_types_tests.rs @@ -0,0 +1,351 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::path::PathBuf; + +#[test] +fn deserialize_stdio_command_server_config() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + "#, + ) + .expect("should deserialize command config"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: Vec::new(), + cwd: None, + } + ); + assert!(cfg.enabled); + assert!(!cfg.required); + assert!(cfg.enabled_tools.is_none()); + assert!(cfg.disabled_tools.is_none()); +} + +#[test] +fn deserialize_stdio_command_server_config_with_args() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + args = ["hello", "world"] + "#, + ) + .expect("should deserialize command config"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec!["hello".to_string(), "world".to_string()], + env: None, + env_vars: Vec::new(), + cwd: None, + } + ); + assert!(cfg.enabled); +} + +#[test] +fn deserialize_stdio_command_server_config_with_arg_with_args_and_env() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + args = ["hello", "world"] + env = { "FOO" = "BAR" } + "#, + ) + .expect("should deserialize command config"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec!["hello".to_string(), "world".to_string()], + env: Some(HashMap::from([("FOO".to_string(), "BAR".to_string())])), + env_vars: Vec::new(), + cwd: None, + } + ); + assert!(cfg.enabled); +} + +#[test] +fn deserialize_stdio_command_server_config_with_env_vars() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + env_vars = ["FOO", "BAR"] + "#, + ) + .expect("should deserialize command config with env_vars"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: vec!["FOO".to_string(), "BAR".to_string()], + cwd: None, + } + ); +} + +#[test] +fn deserialize_stdio_command_server_config_with_cwd() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + cwd = "/tmp" + "#, + ) + .expect("should deserialize command config with cwd"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: Vec::new(), + cwd: Some(PathBuf::from("/tmp")), + } + ); +} + +#[test] +fn deserialize_disabled_server_config() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + enabled = false + "#, + ) + .expect("should deserialize disabled server config"); + + assert!(!cfg.enabled); + assert!(!cfg.required); +} + +#[test] +fn deserialize_required_server_config() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + required = true + "#, + ) + .expect("should deserialize required server config"); + + assert!(cfg.required); +} + +#[test] +fn deserialize_streamable_http_server_config() { + let cfg: McpServerConfig = toml::from_str( + r#" + url = "https://example.com/mcp" + "#, + ) + .expect("should deserialize http config"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + } + ); + assert!(cfg.enabled); +} + +#[test] +fn deserialize_streamable_http_server_config_with_env_var() { + let cfg: McpServerConfig = toml::from_str( + r#" + url = "https://example.com/mcp" + bearer_token_env_var = "GITHUB_TOKEN" + "#, + ) + .expect("should deserialize http config"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: Some("GITHUB_TOKEN".to_string()), + http_headers: None, + env_http_headers: None, + } + ); + assert!(cfg.enabled); +} + +#[test] +fn deserialize_streamable_http_server_config_with_headers() { + let cfg: McpServerConfig = toml::from_str( + r#" + url = "https://example.com/mcp" + http_headers = { "X-Foo" = "bar" } + env_http_headers = { "X-Token" = "TOKEN_ENV" } + "#, + ) + .expect("should deserialize http config with headers"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: None, + http_headers: Some(HashMap::from([("X-Foo".to_string(), "bar".to_string())])), + env_http_headers: Some(HashMap::from([( + "X-Token".to_string(), + "TOKEN_ENV".to_string() + )])), + } + ); +} + +#[test] +fn deserialize_streamable_http_server_config_with_oauth_resource() { + let cfg: McpServerConfig = toml::from_str( + r#" + url = "https://example.com/mcp" + oauth_resource = "https://api.example.com" + "#, + ) + .expect("should deserialize http config with oauth_resource"); + + assert_eq!( + cfg.oauth_resource, + Some("https://api.example.com".to_string()) + ); +} + +#[test] +fn deserialize_server_config_with_tool_filters() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + enabled_tools = ["allowed"] + disabled_tools = ["blocked"] + "#, + ) + .expect("should deserialize tool filters"); + + assert_eq!(cfg.enabled_tools, Some(vec!["allowed".to_string()])); + assert_eq!(cfg.disabled_tools, Some(vec!["blocked".to_string()])); +} + +#[test] +fn deserialize_ignores_unknown_server_fields() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + trust_level = "trusted" + "#, + ) + .expect("should ignore unknown server fields"); + + assert_eq!( + cfg, + 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(), + } + ); +} + +#[test] +fn deserialize_rejects_command_and_url() { + toml::from_str::( + r#" + command = "echo" + url = "https://example.com" + "#, + ) + .expect_err("should reject command+url"); +} + +#[test] +fn deserialize_rejects_env_for_http_transport() { + toml::from_str::( + r#" + url = "https://example.com" + env = { "FOO" = "BAR" } + "#, + ) + .expect_err("should reject env for http transport"); +} + +#[test] +fn deserialize_rejects_headers_for_stdio() { + toml::from_str::( + r#" + command = "echo" + http_headers = { "X-Foo" = "bar" } + "#, + ) + .expect_err("should reject http_headers for stdio transport"); + + toml::from_str::( + r#" + command = "echo" + env_http_headers = { "X-Foo" = "BAR_ENV" } + "#, + ) + .expect_err("should reject env_http_headers for stdio transport"); + + let err = toml::from_str::( + r#" + command = "echo" + oauth_resource = "https://api.example.com" + "#, + ) + .expect_err("should reject oauth_resource for stdio transport"); + + assert!( + err.to_string() + .contains("oauth_resource is not supported for stdio"), + "unexpected error: {err}" + ); +} + +#[test] +fn deserialize_rejects_inline_bearer_token_field() { + let err = toml::from_str::( + r#" + url = "https://example.com" + bearer_token = "secret" + "#, + ) + .expect_err("should reject bearer_token field"); + + assert!( + err.to_string().contains("bearer_token is not supported"), + "unexpected error: {err}" + ); +} diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index e40066027d..d576a7550c 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -38,6 +38,7 @@ codex-core-skills = { workspace = true } codex-exec-server = { workspace = true } codex-features = { workspace = true } codex-login = { workspace = true } +codex-mcp = { workspace = true } codex-shell-command = { workspace = true } codex-execpolicy = { workspace = true } codex-git-utils = { workspace = true } @@ -146,9 +147,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-otel = { workspace = true } codex-utils-cargo-bin = { workspace = true } core_test_support = { workspace = true } ctor = { workspace = true } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index e36f495a1b..99a38edf47 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -392,6 +392,9 @@ "fast_mode": { "type": "boolean" }, + "general_analytics": { + "type": "boolean" + }, "guardian_approval": { "type": "boolean" }, @@ -816,10 +819,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" @@ -1264,6 +1319,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, @@ -2034,6 +2090,9 @@ "fast_mode": { "type": "boolean" }, + "general_analytics": { + "type": "boolean" + }, "guardian_approval": { "type": "boolean" }, @@ -2568,4 +2627,4 @@ }, "title": "ConfigToml", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/core/src/agent/agent_resolver.rs b/codex-rs/core/src/agent/agent_resolver.rs index 3d1f75f57f..14f21c731c 100644 --- a/codex-rs/core/src/agent/agent_resolver.rs +++ b/codex-rs/core/src/agent/agent_resolver.rs @@ -28,25 +28,6 @@ pub(crate) async fn resolve_agent_target( }) } -/// 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 9157228df3..c522acd1c1 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -15,6 +15,7 @@ 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; @@ -41,9 +42,16 @@ 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)] @@ -178,83 +186,32 @@ 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()); @@ -273,17 +230,19 @@ impl AgentControl { self.send_input(new_thread.thread_id, initial_operation) .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(), - ); + 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, @@ -292,6 +251,92 @@ 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 { + let Some(call_id) = options.fork_parent_spawn_call_id.as_deref() else { + 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); + } + + 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.to_string(), + output, + }, + )); + + 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, @@ -445,17 +490,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, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 2def29af9a..834ab2037b 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -448,12 +448,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; @@ -607,6 +602,7 @@ async fn spawn_agent_can_fork_parent_thread_history() { })), SpawnAgentOptions { fork_parent_spawn_call_id: Some(parent_spawn_call_id), + fork_mode: Some(SpawnAgentForkMode::FullHistory), }, ) .await @@ -692,6 +688,7 @@ 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::FullHistory), }, ) .await @@ -764,6 +761,7 @@ async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() { })), SpawnAgentOptions { fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()), + fork_mode: Some(SpawnAgentForkMode::FullHistory), }, ) .await @@ -810,6 +808,128 @@ async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() { .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(); + 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(spawn_turn_context.as_ref(), &[parent_spawn_call]) + .await; + parent_thread + .codex + .session + .ensure_rollout_materialized() + .await; + parent_thread.codex.session.flush_rollout().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), + fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)), + }, + ) + .await + .expect("forked spawn should keep only the last two turns") + .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(), + "old parent context" + )); + assert!(!history_contains_text( + history.raw_items(), + "queued message" + )); + assert!(history_contains_text( + history.raw_items(), + "triggered context" + )); + assert!(history_contains_text( + history.raw_items(), + "current parent task" + )); + + 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_respects_max_threads_limit() { let max_threads = 1usize; 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/auth_env_telemetry.rs b/codex-rs/core/src/auth_env_telemetry.rs index cc5ffa1207..583d79de16 100644 --- a/codex-rs/core/src/auth_env_telemetry.rs +++ b/codex-rs/core/src/auth_env_telemetry.rs @@ -64,6 +64,7 @@ mod tests { env_key: Some("sk-should-not-leak".to_string()), env_key_instructions: None, experimental_bearer_token: None, + auth: None, wire_api: crate::model_provider_info::WireApi::Responses, query_params: None, http_headers: None, diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index af5e3007b4..57e5db1d0d 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -104,6 +104,7 @@ 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::provider_auth::auth_manager_for_provider; 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; @@ -261,6 +262,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()); @@ -294,6 +296,10 @@ impl ModelClient { } } + pub(crate) fn auth_manager(&self) -> Option> { + self.state.auth_manager.clone() + } + fn take_cached_websocket_session(&self) -> WebsocketSession { let mut cached_websocket_session = self .state diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 908dec56c6..ccc2f62975 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -156,14 +156,6 @@ fn strip_total_output_header(output: &str) -> Option<(&str, u32)> { Some((remainder, total_lines)) } -pub(crate) mod tools { - pub(crate) use codex_tools::FreeformTool; - pub(crate) use codex_tools::FreeformToolFormat; - pub(crate) use codex_tools::ResponsesApiTool; - pub(crate) use codex_tools::ToolSearchOutputTool; - pub(crate) use codex_tools::ToolSpec; -} - pub struct ResponseStream { pub(crate) rx_event: mpsc::Receiver>, } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 4dca8dc685..ccc54a575b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -11,7 +11,10 @@ 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; @@ -37,6 +40,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; @@ -97,6 +101,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; @@ -118,6 +123,7 @@ use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationResponse; use codex_rmcp_client::OAuthCredentialsStoreMode; 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; @@ -259,11 +265,11 @@ 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; @@ -341,8 +347,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; @@ -369,6 +373,8 @@ use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::NonSteerableTurnKind; 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; @@ -806,7 +812,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, @@ -936,7 +944,9 @@ 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_agent_type_description(crate::agent::role::spawn_tool_spec::build( + &config.agent_roles, + )); Self { sub_id: self.sub_id.clone(), @@ -1388,13 +1398,15 @@ 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_agent_type_description(crate::agent::role::spawn_tool_spec::build( + &per_turn_config.agent_roles, + )); let cwd = session_configuration.cwd.clone(); @@ -1907,6 +1919,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(), @@ -1917,6 +1930,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, @@ -1985,6 +2000,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(), @@ -2629,6 +2645,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) @@ -2644,6 +2662,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; @@ -3957,6 +4042,18 @@ impl Session { } } + 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() { @@ -3970,17 +4067,37 @@ 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 = { + 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() + } + None => Vec::new(), } - 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; @@ -3999,6 +4116,9 @@ impl Session { } pub async fn has_pending_input(&self) -> bool { + if self.mailbox_rx.lock().await.has_pending() { + return true; + } let active = self.active_turn.lock().await; match active.as_ref() { Some(at) => { @@ -4131,16 +4251,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(), @@ -4158,6 +4274,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(), @@ -4500,8 +4617,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; @@ -4510,6 +4625,8 @@ mod handlers { use crate::tasks::UserShellCommandMode; use crate::tasks::UserShellCommandTask; use crate::tasks::execute_user_shell_command; + use codex_mcp::mcp::auth::compute_auth_statuses; + use codex_mcp::mcp::collect_mcp_snapshot_from_manager; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; @@ -4657,25 +4774,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; } } @@ -5361,13 +5470,15 @@ 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_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(); @@ -6511,7 +6622,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(), ) diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 3f1508cca1..b968d219d2 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -516,7 +516,6 @@ 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 .keys() .map(|path| parent_ctx.cwd.join(path).ok()) @@ -557,7 +556,6 @@ async fn handle_patch_approval( id: approval_id.clone(), cwd: parent_ctx.cwd.to_path_buf(), files, - change_count, patch, }, reason.clone(), diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 29efd92d71..a10b0c33e0 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; @@ -317,11 +318,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 f1a6652770..22681f7a4b 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2654,11 +2654,9 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { 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, @@ -2737,6 +2735,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { skills_outcome, ); + let (mailbox, mailbox_rx) = crate::agent::Mailbox::new(); let session = Session { conversation_id, tx_event, @@ -2747,6 +2746,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, @@ -3494,11 +3495,9 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( 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, @@ -3577,6 +3576,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( skills_outcome, )); + let (mailbox, mailbox_rx) = crate::agent::Mailbox::new(); let session = Arc::new(Session { conversation_id, tx_event, @@ -3587,6 +3587,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, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 0635718c9c..8b467fc10b 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -180,10 +180,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) diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 00227fe2b3..80e369f696 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -15,6 +15,7 @@ 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_features::Feature; @@ -243,6 +244,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#" @@ -2047,6 +2068,30 @@ approval_mode = "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()?; @@ -4315,6 +4360,7 @@ model_verbosity = "high" wire_api: crate::WireApi::Responses, env_key_instructions: None, experimental_bearer_token: None, + auth: None, query_params: None, http_headers: None, env_http_headers: None, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 1a0722119b..785238e265 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -66,6 +66,7 @@ use codex_features::FeatureOverrides; use codex_features::Features; use codex_features::FeaturesToml; use codex_git_utils::resolve_root_git_project_for_trust; +use codex_mcp::mcp::McpConfig; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; @@ -683,6 +684,31 @@ impl ConfigBuilder { } impl Config { + 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)>, @@ -698,6 +724,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) + } + + /// Load a default configuration for a specific Codex home without reading + /// user, project, or system config layers. + pub 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, @@ -1837,6 +1872,18 @@ Built-in providers cannot be overridden. Rename your custom provider (for exampl } } +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> @@ -1844,7 +1891,7 @@ where D: serde::Deserializer<'de>, { let model_providers = HashMap::::deserialize(deserializer)?; - validate_reserved_model_provider_ids(&model_providers).map_err(serde::de::Error::custom)?; + validate_model_providers(&model_providers).map_err(serde::de::Error::custom)?; Ok(model_providers) } @@ -1969,7 +2016,7 @@ 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. diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 05fbaaaa4d..b83be53e03 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -3,7 +3,12 @@ // 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 codex_config::AppToolApproval; +pub use codex_config::McpServerConfig; +pub use codex_config::McpServerDisabledReason; +pub use codex_config::McpServerToolConfig; +pub use codex_config::McpServerTransportConfig; +pub use codex_config::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,10 @@ 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 +} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "kebab-case")] pub enum WindowsSandboxModeToml { @@ -47,272 +52,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, - - /// 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 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, - /// Legacy display-name field accepted for backward compatibility. - #[serde(default, rename = "name")] - pub _name: Option, - #[serde(default)] - pub tools: 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(); - let tools = raw.tools.clone().unwrap_or_default(); - - 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, - tools, - }) - } -} - -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")] @@ -498,24 +237,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, -} - -/// 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, -} - /// Default settings that apply to all apps. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] diff --git a/codex-rs/core/src/config/types_tests.rs b/codex-rs/core/src/config/types_tests.rs index cba01e7876..fbcd2bdc64 100644 --- a/codex-rs/core/src/config/types_tests.rs +++ b/codex-rs/core/src/config/types_tests.rs @@ -1,282 +1,6 @@ use super::*; use pretty_assertions::assert_eq; -#[test] -fn deserialize_stdio_command_server_config() { - let cfg: McpServerConfig = toml::from_str( - r#" - command = "echo" - "#, - ) - .expect("should deserialize command config"); - - assert_eq!( - cfg.transport, - McpServerTransportConfig::Stdio { - command: "echo".to_string(), - args: vec![], - env: None, - env_vars: Vec::new(), - cwd: None, - } - ); - assert!(cfg.enabled); - assert!(!cfg.required); - assert!(cfg.enabled_tools.is_none()); - assert!(cfg.disabled_tools.is_none()); -} - -#[test] -fn deserialize_stdio_command_server_config_with_args() { - let cfg: McpServerConfig = toml::from_str( - r#" - command = "echo" - args = ["hello", "world"] - "#, - ) - .expect("should deserialize command config"); - - assert_eq!( - cfg.transport, - McpServerTransportConfig::Stdio { - command: "echo".to_string(), - args: vec!["hello".to_string(), "world".to_string()], - env: None, - env_vars: Vec::new(), - cwd: None, - } - ); - assert!(cfg.enabled); -} - -#[test] -fn deserialize_stdio_command_server_config_with_arg_with_args_and_env() { - let cfg: McpServerConfig = toml::from_str( - r#" - command = "echo" - args = ["hello", "world"] - env = { "FOO" = "BAR" } - "#, - ) - .expect("should deserialize command config"); - - assert_eq!( - cfg.transport, - McpServerTransportConfig::Stdio { - command: "echo".to_string(), - args: vec!["hello".to_string(), "world".to_string()], - env: Some(HashMap::from([("FOO".to_string(), "BAR".to_string())])), - env_vars: Vec::new(), - cwd: None, - } - ); - assert!(cfg.enabled); -} - -#[test] -fn deserialize_stdio_command_server_config_with_env_vars() { - let cfg: McpServerConfig = toml::from_str( - r#" - command = "echo" - env_vars = ["FOO", "BAR"] - "#, - ) - .expect("should deserialize command config with env_vars"); - - assert_eq!( - cfg.transport, - McpServerTransportConfig::Stdio { - command: "echo".to_string(), - args: vec![], - env: None, - env_vars: vec!["FOO".to_string(), "BAR".to_string()], - cwd: None, - } - ); -} - -#[test] -fn deserialize_stdio_command_server_config_with_cwd() { - let cfg: McpServerConfig = toml::from_str( - r#" - command = "echo" - cwd = "/tmp" - "#, - ) - .expect("should deserialize command config with cwd"); - - assert_eq!( - cfg.transport, - McpServerTransportConfig::Stdio { - command: "echo".to_string(), - args: vec![], - env: None, - env_vars: Vec::new(), - cwd: Some(PathBuf::from("/tmp")), - } - ); -} - -#[test] -fn deserialize_disabled_server_config() { - let cfg: McpServerConfig = toml::from_str( - r#" - command = "echo" - enabled = false - "#, - ) - .expect("should deserialize disabled server config"); - - assert!(!cfg.enabled); - assert!(!cfg.required); -} - -#[test] -fn deserialize_required_server_config() { - let cfg: McpServerConfig = toml::from_str( - r#" - command = "echo" - required = true - "#, - ) - .expect("should deserialize required server config"); - - assert!(cfg.required); -} - -#[test] -fn deserialize_streamable_http_server_config() { - let cfg: McpServerConfig = toml::from_str( - r#" - url = "https://example.com/mcp" - "#, - ) - .expect("should deserialize http config"); - - assert_eq!( - cfg.transport, - McpServerTransportConfig::StreamableHttp { - url: "https://example.com/mcp".to_string(), - bearer_token_env_var: None, - http_headers: None, - env_http_headers: None, - } - ); - assert!(cfg.enabled); -} - -#[test] -fn deserialize_streamable_http_server_config_with_env_var() { - let cfg: McpServerConfig = toml::from_str( - r#" - url = "https://example.com/mcp" - bearer_token_env_var = "GITHUB_TOKEN" - "#, - ) - .expect("should deserialize http config"); - - assert_eq!( - cfg.transport, - McpServerTransportConfig::StreamableHttp { - url: "https://example.com/mcp".to_string(), - bearer_token_env_var: Some("GITHUB_TOKEN".to_string()), - http_headers: None, - env_http_headers: None, - } - ); - assert!(cfg.enabled); -} - -#[test] -fn deserialize_streamable_http_server_config_with_headers() { - let cfg: McpServerConfig = toml::from_str( - r#" - url = "https://example.com/mcp" - http_headers = { "X-Foo" = "bar" } - env_http_headers = { "X-Token" = "TOKEN_ENV" } - "#, - ) - .expect("should deserialize http config with headers"); - - assert_eq!( - cfg.transport, - McpServerTransportConfig::StreamableHttp { - url: "https://example.com/mcp".to_string(), - bearer_token_env_var: None, - http_headers: Some(HashMap::from([("X-Foo".to_string(), "bar".to_string())])), - env_http_headers: Some(HashMap::from([( - "X-Token".to_string(), - "TOKEN_ENV".to_string() - )])), - } - ); -} - -#[test] -fn deserialize_streamable_http_server_config_with_oauth_resource() { - let cfg: McpServerConfig = toml::from_str( - r#" - url = "https://example.com/mcp" - oauth_resource = "https://api.example.com" - "#, - ) - .expect("should deserialize http config with oauth_resource"); - - assert_eq!( - cfg.oauth_resource, - Some("https://api.example.com".to_string()) - ); -} - -#[test] -fn deserialize_server_config_with_tool_filters() { - let cfg: McpServerConfig = toml::from_str( - r#" - command = "echo" - enabled_tools = ["allowed"] - disabled_tools = ["blocked"] - "#, - ) - .expect("should deserialize tool filters"); - - assert_eq!(cfg.enabled_tools, Some(vec!["allowed".to_string()])); - assert_eq!(cfg.disabled_tools, Some(vec!["blocked".to_string()])); -} - -#[test] -fn deserialize_ignores_unknown_server_fields() { - let cfg: McpServerConfig = toml::from_str( - r#" - command = "echo" - trust_level = "trusted" - "#, - ) - .expect("should ignore unknown server fields"); - - assert_eq!( - cfg, - 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(), - } - ); -} - #[test] fn deserialize_skill_config_with_name_selector() { let cfg: SkillConfig = toml::from_str( @@ -317,74 +41,3 @@ fn deserialize_skill_config_with_path_selector() { } ); } - -#[test] -fn deserialize_rejects_command_and_url() { - toml::from_str::( - r#" - command = "echo" - url = "https://example.com" - "#, - ) - .expect_err("should reject command+url"); -} - -#[test] -fn deserialize_rejects_env_for_http_transport() { - toml::from_str::( - r#" - url = "https://example.com" - env = { "FOO" = "BAR" } - "#, - ) - .expect_err("should reject env for http transport"); -} - -#[test] -fn deserialize_rejects_headers_for_stdio() { - toml::from_str::( - r#" - command = "echo" - http_headers = { "X-Foo" = "bar" } - "#, - ) - .expect_err("should reject http_headers for stdio transport"); - - toml::from_str::( - r#" - command = "echo" - env_http_headers = { "X-Foo" = "BAR_ENV" } - "#, - ) - .expect_err("should reject env_http_headers for stdio transport"); - - let err = toml::from_str::( - r#" - command = "echo" - oauth_resource = "https://api.example.com" - "#, - ) - .expect_err("should reject oauth_resource for stdio transport"); - - assert!( - err.to_string() - .contains("oauth_resource is not supported for stdio"), - "unexpected error: {err}" - ); -} - -#[test] -fn deserialize_rejects_inline_bearer_token_field() { - let err = toml::from_str::( - r#" - url = "https://example.com" - bearer_token = "secret" - "#, - ) - .expect_err("should reject bearer_token field"); - - assert!( - err.to_string().contains("bearer_token is not supported"), - "unexpected error: {err}" - ); -} diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index e4999b9b41..9c149309ff 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -18,6 +18,7 @@ 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; @@ -26,6 +27,7 @@ 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; @@ -44,8 +46,6 @@ 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::tools::discoverable::DiscoverablePluginInfo; -use crate::tools::discoverable::DiscoverableTool; use codex_features::Feature; pub use codex_connectors::CONNECTORS_CACHE_TTL; @@ -133,7 +133,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) @@ -191,7 +190,8 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( }); } 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 +203,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 +230,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(), @@ -719,10 +716,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/exec.rs b/codex-rs/core/src/exec.rs index fde8782f08..0582232d91 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -314,7 +314,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 { @@ -328,13 +327,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, @@ -354,7 +352,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, 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..e2b72bd049 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -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/review.rs b/codex-rs/core/src/guardian/review.rs index 3a491f6efb..23d5cb99b0 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; diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index c0df326ebe..fa59cf908a 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -265,7 +265,6 @@ fn format_guardian_action_pretty_truncates_large_string_fields() -> serde_json:: id: "patch-1".to_string(), cwd: PathBuf::from("/tmp"), files: Vec::new(), - change_count: 1usize, patch: patch.clone(), }; @@ -318,7 +317,7 @@ fn guardian_approval_request_to_json_renders_mcp_tool_call_shape() -> serde_json } #[test] -fn guardian_assessment_action_value_redacts_apply_patch_patch_text() { +fn guardian_assessment_action_redacts_apply_patch_patch_text() { let (cwd, file) = if cfg!(windows) { (r"C:\tmp", r"C:\tmp\guardian.txt") } else { @@ -330,19 +329,17 @@ fn guardian_assessment_action_value_redacts_apply_patch_patch_text() { id: "patch-1".to_string(), cwd: cwd.clone(), files: vec![file.clone()], - change_count: 1usize, patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+secret\n*** End Patch" .to_string(), }; assert_eq!( - guardian_assessment_action_value(&action), + serde_json::to_value(guardian_assessment_action(&action)).expect("serialize action"), serde_json::json!({ - "tool": "apply_patch", + "type": "apply_patch", "cwd": cwd, "files": [file], - "change_count": 1, - }) + }), ); } @@ -360,7 +357,6 @@ fn guardian_request_turn_id_prefers_network_access_owner_turn() { id: "patch-1".to_string(), cwd: PathBuf::from("/tmp"), files: vec![PathBuf::from("/tmp/guardian.txt").abs()], - change_count: 1usize, patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+hello\n*** End Patch" .to_string(), }; @@ -388,7 +384,6 @@ async fn cancelled_guardian_review_emits_terminal_abort_without_warning() { id: "patch-1".to_string(), cwd: PathBuf::from("/tmp"), files: vec![PathBuf::from("/tmp/guardian.txt").abs()], - change_count: 1usize, patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+hello\n*** End Patch" .to_string(), }, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 5276b09de3..4b8510bc8e 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -46,15 +46,16 @@ mod hook_runtime; pub mod instructions; pub mod landlock; pub mod mcp; -mod mcp_connection_manager; +mod mcp_skill_dependencies; mod mcp_tool_approval_templates; pub mod models_manager; mod network_policy_decision; pub mod network_proxy_loader; mod original_image_detail; -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 codex_mcp::mcp_connection_manager; +pub use codex_mcp::mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY; +pub use codex_mcp::mcp_connection_manager::MCP_SANDBOX_STATE_METHOD; +pub use codex_mcp::mcp_connection_manager::SandboxState; pub use text_encoding::bytes_to_string_smart; mod mcp_tool_call; mod memories; @@ -65,6 +66,7 @@ pub mod utils; pub use utils::path_utils; pub mod personality_migration; pub mod plugins; +mod provider_auth; pub(crate) mod mentions { pub(crate) use crate::plugins::build_connector_slug_counts; pub(crate) use crate::plugins::build_skill_name_counts; @@ -93,7 +95,6 @@ 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; @@ -104,6 +105,7 @@ mod text_encoding; mod unified_exec; pub mod windows_sandbox; pub use client::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER; +pub use codex_protocol::config_types::ModelProviderAuthInfo; 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; @@ -133,6 +135,7 @@ pub type CodexConversation = CodexThread; pub use auth::AuthManager; pub use auth::CodexAuth; pub use codex_analytics::AnalyticsEventsClient; +pub use codex_analytics::AppServerRpcTransport; mod default_client_forwarding; /// Default Codex HTTP client headers and reqwest construction. diff --git a/codex-rs/core/src/mcp.rs b/codex-rs/core/src/mcp.rs new file mode 100644 index 0000000000..1a5861c325 --- /dev/null +++ b/codex-rs/core/src/mcp.rs @@ -0,0 +1,53 @@ +use std::collections::HashMap; +use std::sync::Arc; + +pub use codex_mcp::mcp::CODEX_APPS_MCP_SERVER_NAME; +pub use codex_mcp::mcp::McpConfig; +pub use codex_mcp::mcp::ToolPluginProvenance; +pub use codex_mcp::mcp::auth; +pub use codex_mcp::mcp::canonical_mcp_server_key; +pub use codex_mcp::mcp::collect_mcp_snapshot; +pub use codex_mcp::mcp::collect_mcp_snapshot_from_manager; +pub use codex_mcp::mcp::collect_missing_mcp_dependencies; +pub use codex_mcp::mcp::configured_mcp_servers; +pub use codex_mcp::mcp::effective_mcp_servers; +pub use codex_mcp::mcp::group_tools_by_server; +pub use codex_mcp::mcp::qualified_mcp_tool_name_prefix; +pub use codex_mcp::mcp::split_qualified_tool_name; +pub use codex_mcp::mcp::tool_plugin_provenance as mcp_tool_plugin_provenance; +pub use codex_mcp::mcp::with_codex_apps_mcp; + +use crate::CodexAuth; +use crate::config::Config; +use crate::plugins::PluginsManager; +use codex_config::McpServerConfig; + +#[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()); + mcp_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 ba4ef26488..8c7000293b 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::mcp::auth::McpOAuthLoginSupport; +use crate::mcp::auth::oauth_login_support; +use crate::mcp::auth::resolve_oauth_scopes; +use crate::mcp::auth::should_retry_without_scopes; +use crate::skills::model::SkillToolDependency; 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,63 +352,6 @@ fn canonical_mcp_dependency_key(dependency: &SkillToolDependency) -> Result, -) -> 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 mcp_dependency_to_server_config( dependency: &SkillToolDependency, ) -> Result { @@ -461,6 +410,59 @@ fn mcp_dependency_to_server_config( Err(format!("unsupported transport {transport}")) } -#[cfg(test)] -#[path = "skill_dependencies_tests.rs"] -mod tests; +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 +} diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 737a47780d..a3df34b64d 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -9,6 +9,7 @@ use crate::auth::AuthMode; use crate::error::EnvVarError; use codex_api::Provider as ApiProvider; use codex_api::provider::RetryConfig as ApiRetryConfig; +use codex_protocol::config_types::ModelProviderAuthInfo; use http::HeaderMap; use http::header::HeaderName; use http::header::HeaderValue; @@ -86,6 +87,9 @@ pub struct ModelProviderInfo { /// this may be necessary when using this programmatically. pub experimental_bearer_token: Option, + /// Command-backed bearer-token configuration for this provider. + pub auth: Option, + /// Which wire protocol this provider expects. #[serde(default)] pub wire_api: WireApi, @@ -130,6 +134,36 @@ pub struct ModelProviderInfo { } impl ModelProviderInfo { + pub(crate) fn validate(&self) -> std::result::Result<(), String> { + let Some(auth) = self.auth.as_ref() else { + return Ok(()); + }; + + if auth.command.trim().is_empty() { + return Err("provider auth.command must not be empty".to_string()); + } + + let mut conflicts = Vec::new(); + if self.env_key.is_some() { + conflicts.push("env_key"); + } + if self.experimental_bearer_token.is_some() { + conflicts.push("experimental_bearer_token"); + } + if self.requires_openai_auth { + conflicts.push("requires_openai_auth"); + } + + if conflicts.is_empty() { + Ok(()) + } else { + Err(format!( + "provider auth cannot be combined with {}", + conflicts.join(", ") + )) + } + } + fn build_header_map(&self) -> crate::error::Result { let capacity = self.http_headers.as_ref().map_or(0, HashMap::len) + self.env_http_headers.as_ref().map_or(0, HashMap::len); @@ -246,6 +280,7 @@ impl ModelProviderInfo { env_key: None, env_key_instructions: None, experimental_bearer_token: None, + auth: None, wire_api: WireApi::Responses, query_params: None, http_headers: Some( @@ -277,6 +312,10 @@ impl ModelProviderInfo { pub fn is_openai(&self) -> bool { self.name == OPENAI_PROVIDER_NAME } + + pub(crate) fn has_command_auth(&self) -> bool { + self.auth.is_some() + } } pub const DEFAULT_LMSTUDIO_PORT: u16 = 1234; @@ -338,6 +377,7 @@ pub fn create_oss_provider_with_base_url(base_url: &str, wire_api: WireApi) -> M env_key: None, env_key_instructions: None, experimental_bearer_token: None, + auth: None, wire_api, query_params: None, http_headers: None, diff --git a/codex-rs/core/src/model_provider_info_tests.rs b/codex-rs/core/src/model_provider_info_tests.rs index a5309117ae..e456a6a5c2 100644 --- a/codex-rs/core/src/model_provider_info_tests.rs +++ b/codex-rs/core/src/model_provider_info_tests.rs @@ -1,5 +1,9 @@ use super::*; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::AbsolutePathBufGuard; use pretty_assertions::assert_eq; +use std::num::NonZeroU64; +use tempfile::tempdir; #[test] fn test_deserialize_ollama_model_provider_toml() { @@ -13,6 +17,7 @@ base_url = "http://localhost:11434/v1" env_key: None, env_key_instructions: None, experimental_bearer_token: None, + auth: None, wire_api: WireApi::Responses, query_params: None, http_headers: None, @@ -43,6 +48,7 @@ query_params = { api-version = "2025-04-01-preview" } env_key: Some("AZURE_OPENAI_API_KEY".into()), env_key_instructions: None, experimental_bearer_token: None, + auth: None, wire_api: WireApi::Responses, query_params: Some(maplit::hashmap! { "api-version".to_string() => "2025-04-01-preview".to_string(), @@ -76,6 +82,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } env_key: Some("API_KEY".into()), env_key_instructions: None, experimental_bearer_token: None, + auth: None, wire_api: WireApi::Responses, query_params: None, http_headers: Some(maplit::hashmap! { @@ -121,3 +128,52 @@ supports_websockets = true let provider: ModelProviderInfo = toml::from_str(provider_toml).unwrap(); assert_eq!(provider.websocket_connect_timeout_ms, Some(15_000)); } + +#[test] +fn test_deserialize_provider_auth_config_defaults() { + let base_dir = tempdir().unwrap(); + let provider_toml = r#" +name = "Corp" + +[auth] +command = "./scripts/print-token" +args = ["--format=text"] + "#; + + let provider: ModelProviderInfo = { + let _guard = AbsolutePathBufGuard::new(base_dir.path()); + toml::from_str(provider_toml).unwrap() + }; + + assert_eq!( + provider.auth, + Some(ModelProviderAuthInfo { + command: "./scripts/print-token".to_string(), + args: vec!["--format=text".to_string()], + timeout_ms: NonZeroU64::new(5_000).unwrap(), + refresh_interval_ms: 300_000, + cwd: AbsolutePathBuf::resolve_path_against_base(".", base_dir.path()).unwrap(), + }) + ); +} + +#[test] +fn test_deserialize_provider_auth_config_allows_zero_refresh_interval() { + let base_dir = tempdir().unwrap(); + let provider_toml = r#" +name = "Corp" + +[auth] +command = "./scripts/print-token" +refresh_interval_ms = 0 + "#; + + let provider: ModelProviderInfo = { + let _guard = AbsolutePathBufGuard::new(base_dir.path()); + toml::from_str(provider_toml).unwrap() + }; + + let auth = provider.auth.expect("auth config should deserialize"); + assert_eq!(auth.refresh_interval_ms, 0); + assert_eq!(auth.refresh_interval(), None); +} diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 29a1a85767..aa22186f98 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -14,6 +14,7 @@ use crate::model_provider_info::ModelProviderInfo; use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig; use crate::models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; use crate::models_manager::model_info; +use crate::provider_auth::required_auth_manager_for_provider; use crate::response_debug_context::extract_response_debug_context; use crate::response_debug_context::telemetry_transport_error_message; use crate::util::FeedbackRequestTags; @@ -212,6 +213,7 @@ impl ModelsManager { collaboration_modes_config: CollaborationModesConfig, provider: ModelProviderInfo, ) -> Self { + let auth_manager = required_auth_manager_for_provider(auth_manager, &provider); let cache_path = codex_home.join(MODEL_CACHE_FILE); let cache_manager = ModelsCacheManager::new(cache_path, DEFAULT_MODEL_CACHE_TTL); let catalog_mode = if model_catalog.is_some() { @@ -396,7 +398,9 @@ impl ModelsManager { return Ok(()); } - if self.auth_manager.auth_mode() != Some(AuthMode::Chatgpt) { + if self.auth_manager.auth_mode() != Some(AuthMode::Chatgpt) + && !self.provider.has_command_auth() + { if matches!( refresh_strategy, RefreshStrategy::Offline | RefreshStrategy::OnlineIfUncached diff --git a/codex-rs/core/src/models_manager/manager_tests.rs b/codex-rs/core/src/models_manager/manager_tests.rs index 7b4b2be53b..7f79e07052 100644 --- a/codex-rs/core/src/models_manager/manager_tests.rs +++ b/codex-rs/core/src/models_manager/manager_tests.rs @@ -1,5 +1,7 @@ use super::*; +use crate::AuthManager; use crate::CodexAuth; +use crate::ModelProviderAuthInfo; use crate::auth::AuthCredentialsStoreMode; use crate::config::ConfigBuilder; use crate::model_provider_info::WireApi; @@ -13,8 +15,10 @@ use http::StatusCode; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::BTreeMap; +use std::num::NonZeroU64; use std::sync::Arc; use std::sync::Mutex; +use tempfile::TempDir; use tempfile::tempdir; use tracing::Event; use tracing::Subscriber; @@ -24,7 +28,12 @@ use tracing_subscriber::layer::Context; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::registry::LookupSpan; use tracing_subscriber::util::SubscriberInitExt; +use wiremock::Mock; use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header_regex; +use wiremock::matchers::method; +use wiremock::matchers::path; fn remote_model(slug: &str, display: &str, priority: i32) -> ModelInfo { remote_model_with_visibility(slug, display, priority, "list") @@ -79,6 +88,7 @@ fn provider_for(base_url: String) -> ModelProviderInfo { env_key: None, env_key_instructions: None, experimental_bearer_token: None, + auth: None, wire_api: WireApi::Responses, query_params: None, http_headers: None, @@ -92,6 +102,88 @@ fn provider_for(base_url: String) -> ModelProviderInfo { } } +struct ProviderAuthScript { + tempdir: TempDir, + command: String, + args: Vec, +} + +impl ProviderAuthScript { + fn new(tokens: &[&str]) -> std::io::Result { + let tempdir = tempfile::tempdir()?; + let tokens_file = tempdir.path().join("tokens.txt"); + let mut token_file_contents = String::new(); + for token in tokens { + token_file_contents.push_str(token); + token_file_contents.push('\n'); + } + std::fs::write(&tokens_file, token_file_contents)?; + + #[cfg(unix)] + let (command, args) = { + let script_path = tempdir.path().join("print-token.sh"); + std::fs::write( + &script_path, + r#"#!/bin/sh +first_line=$(sed -n '1p' tokens.txt) +printf '%s\n' "$first_line" +tail -n +2 tokens.txt > tokens.next +mv tokens.next tokens.txt +"#, + )?; + let mut permissions = std::fs::metadata(&script_path)?.permissions(); + { + use std::os::unix::fs::PermissionsExt; + permissions.set_mode(0o755); + } + std::fs::set_permissions(&script_path, permissions)?; + ("./print-token.sh".to_string(), Vec::new()) + }; + + #[cfg(windows)] + let (command, args) = { + let script_path = tempdir.path().join("print-token.ps1"); + std::fs::write( + &script_path, + r#"$lines = @(Get-Content -Path tokens.txt) +if ($lines.Count -eq 0) { exit 1 } +Write-Output $lines[0] +$lines | Select-Object -Skip 1 | Set-Content -Path tokens.txt +"#, + )?; + ( + "powershell".to_string(), + vec![ + "-NoProfile".to_string(), + "-ExecutionPolicy".to_string(), + "Bypass".to_string(), + "-File".to_string(), + ".\\print-token.ps1".to_string(), + ], + ) + }; + + Ok(Self { + tempdir, + command, + args, + }) + } + + fn auth_config(&self) -> ModelProviderAuthInfo { + ModelProviderAuthInfo { + command: self.command.clone(), + args: self.args.clone(), + timeout_ms: NonZeroU64::new(/*value*/ 1_000).unwrap(), + refresh_interval_ms: 60_000, + cwd: match codex_utils_absolute_path::AbsolutePathBuf::try_from(self.tempdir.path()) { + Ok(cwd) => cwd, + Err(err) => panic!("tempdir should be absolute: {err}"), + }, + } + } +} + #[derive(Default)] struct TagCollectorVisitor { tags: BTreeMap, @@ -310,6 +402,50 @@ async fn refresh_available_models_sorts_by_priority() { ); } +#[tokio::test] +async fn refresh_available_models_uses_provider_auth_token() { + let server = MockServer::start().await; + let auth_script = ProviderAuthScript::new(&["provider-token"]).unwrap(); + let remote_models = vec![remote_model( + "provider-model", + "Provider", + /*priority*/ 0, + )]; + + Mock::given(method("GET")) + .and(path("/models")) + .and(header_regex("Authorization", "Bearer provider-token")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/json") + .set_body_json(ModelsResponse { + models: remote_models.clone(), + }), + ) + .expect(1) + .mount(&server) + .await; + + let codex_home = tempdir().expect("temp dir"); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("unused")); + let provider = ModelProviderInfo { + auth: Some(auth_script.auth_config()), + ..provider_for(server.uri()) + }; + let manager = ModelsManager::with_provider_for_tests( + codex_home.path().to_path_buf(), + auth_manager, + provider, + ); + + manager + .refresh_available_models(RefreshStrategy::Online) + .await + .expect("refresh succeeds"); + + assert_models_contain(&manager.get_remote_models().await, &remote_models); +} + #[tokio::test] async fn refresh_available_models_uses_cache_when_fresh() { let server = MockServer::start().await; diff --git a/codex-rs/core/src/original_image_detail.rs b/codex-rs/core/src/original_image_detail.rs index 8db219f123..c3e5c3d9a5 100644 --- a/codex-rs/core/src/original_image_detail.rs +++ b/codex-rs/core/src/original_image_detail.rs @@ -1,28 +1,2 @@ -use codex_features::Feature; -use codex_features::Features; -use codex_protocol::models::ImageDetail; -use codex_protocol::openai_models::ModelInfo; - -pub(crate) fn can_request_original_image_detail( - features: &Features, - model_info: &ModelInfo, -) -> bool { - model_info.supports_image_detail_original && features.enabled(Feature::ImageDetailOriginal) -} - -pub(crate) fn normalize_output_image_detail( - features: &Features, - model_info: &ModelInfo, - detail: Option, -) -> Option { - match detail { - Some(ImageDetail::Original) if can_request_original_image_detail(features, model_info) => { - Some(ImageDetail::Original) - } - Some(ImageDetail::Original) | Some(_) | None => None, - } -} - -#[cfg(test)] -#[path = "original_image_detail_tests.rs"] -mod tests; +pub(crate) use codex_tools::can_request_original_image_detail; +pub(crate) use codex_tools::normalize_output_image_detail; diff --git a/codex-rs/core/src/plugins/discoverable.rs b/codex-rs/core/src/plugins/discoverable.rs index 427706235f..f7006185bf 100644 --- a/codex-rs/core/src/plugins/discoverable.rs +++ b/codex-rs/core/src/plugins/discoverable.rs @@ -9,6 +9,7 @@ use super::PluginsManager; use crate::config::Config; use crate::config::types::ToolSuggestDiscoverableType; use codex_features::Feature; +use codex_tools::DiscoverablePluginInfo; const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[ "github@openai-curated", @@ -23,7 +24,7 @@ const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[ pub(crate) fn list_tool_suggest_discoverable_plugins( config: &Config, -) -> anyhow::Result> { +) -> anyhow::Result> { if !config.features.enabled(Feature::Plugins) { return Ok(Vec::new()); } @@ -47,7 +48,7 @@ pub(crate) fn list_tool_suggest_discoverable_plugins( return Ok(Vec::new()); }; - let mut discoverable_plugins = Vec::::new(); + let mut discoverable_plugins = Vec::::new(); for plugin in curated_marketplace.plugins { if plugin.installed || (!TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.id.as_str()) @@ -66,14 +67,28 @@ pub(crate) fn list_tool_suggest_discoverable_plugins( marketplace_path: curated_marketplace.path.clone(), }, ) { - Ok(plugin) => discoverable_plugins.push(plugin.plugin.into()), + Ok(plugin) => { + let plugin: PluginCapabilitySummary = plugin.plugin.into(); + discoverable_plugins.push(DiscoverablePluginInfo { + id: plugin.config_name, + name: plugin.display_name, + description: plugin.description, + has_skills: plugin.has_skills, + mcp_server_names: plugin.mcp_server_names, + app_connector_ids: plugin + .app_connector_ids + .into_iter() + .map(|connector_id| connector_id.0) + .collect(), + }); + } Err(err) => warn!("failed to load discoverable plugin suggestion {plugin_id}: {err:#}"), } } discoverable_plugins.sort_by(|left, right| { - left.display_name - .cmp(&right.display_name) - .then_with(|| left.config_name.cmp(&right.config_name)) + left.name + .cmp(&right.name) + .then_with(|| left.id.cmp(&right.id)) }); Ok(discoverable_plugins) } diff --git a/codex-rs/core/src/plugins/discoverable_tests.rs b/codex-rs/core/src/plugins/discoverable_tests.rs index fd9df9ddf6..70ac887cb6 100644 --- a/codex-rs/core/src/plugins/discoverable_tests.rs +++ b/codex-rs/core/src/plugins/discoverable_tests.rs @@ -5,7 +5,7 @@ use crate::plugins::test_support::write_curated_plugin_sha; use crate::plugins::test_support::write_file; use crate::plugins::test_support::write_openai_curated_marketplace; use crate::plugins::test_support::write_plugins_feature_config; -use crate::tools::discoverable::DiscoverablePluginInfo; +use codex_tools::DiscoverablePluginInfo; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use tempfile::tempdir; @@ -18,11 +18,7 @@ async fn list_tool_suggest_discoverable_plugins_returns_uninstalled_curated_plug write_plugins_feature_config(codex_home.path()); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) - .unwrap() - .into_iter() - .map(DiscoverablePluginInfo::from) - .collect::>(); + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config).unwrap(); assert_eq!( discoverable_plugins, @@ -52,11 +48,7 @@ plugins = false ); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) - .unwrap() - .into_iter() - .map(DiscoverablePluginInfo::from) - .collect::>(); + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config).unwrap(); assert_eq!(discoverable_plugins, Vec::::new()); } @@ -76,11 +68,7 @@ async fn list_tool_suggest_discoverable_plugins_normalizes_description() { ); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) - .unwrap() - .into_iter() - .map(DiscoverablePluginInfo::from) - .collect::>(); + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config).unwrap(); assert_eq!( discoverable_plugins, @@ -115,11 +103,7 @@ async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins( .expect("plugin should install"); let refreshed_config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config) - .unwrap() - .into_iter() - .map(DiscoverablePluginInfo::from) - .collect::>(); + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config).unwrap(); assert_eq!(discoverable_plugins, Vec::::new()); } @@ -140,11 +124,7 @@ discoverables = [{ type = "plugin", id = "sample@openai-curated" }] ); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) - .unwrap() - .into_iter() - .map(DiscoverablePluginInfo::from) - .collect::>(); + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config).unwrap(); assert_eq!( discoverable_plugins, diff --git a/codex-rs/core/src/provider_auth.rs b/codex-rs/core/src/provider_auth.rs new file mode 100644 index 0000000000..4737ccc8bc --- /dev/null +++ b/codex-rs/core/src/provider_auth.rs @@ -0,0 +1,31 @@ +use std::sync::Arc; + +use crate::AuthManager; +use crate::model_provider_info::ModelProviderInfo; + +/// Returns the provider-scoped auth manager when this provider uses command-backed auth. +/// +/// Providers without custom auth continue using the caller-supplied base manager. +pub(crate) fn auth_manager_for_provider( + auth_manager: Option>, + provider: &ModelProviderInfo, +) -> Option> { + match provider.auth.clone() { + Some(config) => Some(AuthManager::external_bearer_only(config)), + None => auth_manager, + } +} + +/// Returns an auth manager for request paths that always require authentication. +/// +/// Providers with command-backed auth get a bearer-only manager; otherwise the caller's manager +/// is reused unchanged. +pub(crate) fn required_auth_manager_for_provider( + auth_manager: Arc, + provider: &ModelProviderInfo, +) -> Arc { + match provider.auth.clone() { + Some(config) => AuthManager::external_bearer_only(config), + None => auth_manager, + } +} diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 1ddd72d0fd..1bfe1b5a88 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -452,7 +452,12 @@ async fn prepare_realtime_start( params: ConversationStartParams, ) -> CodexResult { let provider = sess.provider().await; - let auth = sess.services.auth_manager.auth().await; + let auth_manager = sess + .services + .model_client + .auth_manager() + .unwrap_or_else(|| Arc::clone(&sess.services.auth_manager)); + let auth = auth_manager.auth().await; let realtime_api_key = realtime_api_key(auth.as_ref(), &provider)?; let mut api_provider = provider.to_api_provider(Some(crate::auth::AuthMode::ApiKey))?; let config = sess.get_config().await; diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 26f4f733c6..f2b151ccd8 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -141,14 +141,7 @@ pub async fn execute_env( exec_request: ExecRequest, stdout_stream: Option, ) -> crate::error::Result { - let effective_policy = exec_request.sandbox_policy.clone(); - execute_exec_request( - exec_request, - &effective_policy, - stdout_stream, - /*after_spawn*/ None, - ) - .await + execute_exec_request(exec_request, stdout_stream, /*after_spawn*/ None).await } pub async fn execute_exec_request_with_after_spawn( @@ -156,6 +149,5 @@ pub async fn execute_exec_request_with_after_spawn( stdout_stream: Option, after_spawn: Option>, ) -> crate::error::Result { - let effective_policy = exec_request.sandbox_policy.clone(); - execute_exec_request(exec_request, &effective_policy, stdout_stream, after_spawn).await + execute_exec_request(exec_request, stdout_stream, after_spawn).await } diff --git a/codex-rs/core/src/shell_snapshot_tests.rs b/codex-rs/core/src/shell_snapshot_tests.rs index 90288300ff..ff700ff7a6 100644 --- a/codex-rs/core/src/shell_snapshot_tests.rs +++ b/codex-rs/core/src/shell_snapshot_tests.rs @@ -313,9 +313,15 @@ async fn timed_out_snapshot_shell_is_terminated() -> Result<()> { shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }; - let err = run_script_with_timeout(&shell, &script, Duration::from_secs(1), true, dir.path()) - .await - .expect_err("snapshot shell should time out"); + let err = run_script_with_timeout( + &shell, + &script, + Duration::from_secs(1), + /*use_login_shell*/ true, + dir.path(), + ) + .await + .expect_err("snapshot shell should time out"); assert!( err.to_string().contains("timed out"), "expected timeout error, got {err:?}" diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 601f5cb27f..0f14e233c7 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -58,7 +58,7 @@ pub(crate) use user_shell::UserShellCommandTask; pub(crate) use user_shell::execute_user_shell_command; const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100; -const TURN_ABORTED_INTERRUPTED_GUIDANCE: &str = "The user interrupted the previous turn on purpose. Any running unified exec processes may still be running in the background. If any tools/commands were aborted, they may have partially executed; verify current state before retrying."; +const TURN_ABORTED_INTERRUPTED_GUIDANCE: &str = "The user interrupted the previous turn on purpose. Any running unified exec processes may still be running in the background. If any tools/commands were aborted, they may have partially executed."; /// Shared model-visible marker used by both the real interrupt path and /// interrupted fork snapshots. @@ -191,60 +191,68 @@ impl Session { let cancellation_token = CancellationToken::new(); let done = Arc::new(Notify::new()); - let done_clone = Arc::clone(&done); - let handle = { - let session_ctx = Arc::new(SessionTaskContext::new(Arc::clone(self))); - let ctx = Arc::clone(&turn_context); - let task_for_run = Arc::clone(&task); - let task_cancellation_token = cancellation_token.child_token(); - // Task-owned turn spans keep a core-owned span open for the - // full task lifecycle after the submission dispatch span ends. - let task_span = info_span!( - "turn", - otel.name = span_name, - thread.id = %self.conversation_id, - turn.id = %turn_context.sub_id, - model = %turn_context.model_info.slug, - ); - tokio::spawn( - async move { - let ctx_for_finish = Arc::clone(&ctx); - let last_agent_message = task_for_run - .run( - Arc::clone(&session_ctx), - ctx, - input, - task_cancellation_token.child_token(), - ) - .await; - let sess = session_ctx.clone_session(); - sess.flush_rollout().await; - if !task_cancellation_token.is_cancelled() { - // Emit completion uniformly from spawn site so all tasks share the same lifecycle. - sess.on_task_finished(Arc::clone(&ctx_for_finish), last_agent_message) - .await; - } - done_clone.notify_waiters(); - } - .instrument(task_span), - ) - }; - let queued_response_items = self.take_queued_response_items_for_next_turn().await; - let mut active = self.active_turn.lock().await; - let mut turn = ActiveTurn::default(); - let mut turn_state = turn.turn_state.lock().await; - turn_state.token_usage_at_turn_start = token_usage_at_turn_start; - for item in queued_response_items { - turn_state.push_pending_input(item); + let mailbox_items = self.get_pending_input().await; + let turn_state = { + let mut active = self.active_turn.lock().await; + let turn = active.get_or_insert_with(ActiveTurn::default); + debug_assert!(turn.tasks.is_empty()); + Arc::clone(&turn.turn_state) + }; + { + let mut turn_state = turn_state.lock().await; + turn_state.token_usage_at_turn_start = token_usage_at_turn_start; + for item in queued_response_items { + turn_state.push_pending_input(item); + } + for item in mailbox_items { + turn_state.push_pending_input(item); + } } - drop(turn_state); + let mut active = self.active_turn.lock().await; + let turn = active.get_or_insert_with(ActiveTurn::default); + debug_assert!(turn.tasks.is_empty()); + let done_clone = Arc::clone(&done); + let session_ctx = Arc::new(SessionTaskContext::new(Arc::clone(self))); + let ctx = Arc::clone(&turn_context); + let task_for_run = Arc::clone(&task); + let task_cancellation_token = cancellation_token.child_token(); + // Task-owned turn spans keep a core-owned span open for the + // full task lifecycle after the submission dispatch span ends. + let task_span = info_span!( + "turn", + otel.name = span_name, + thread.id = %self.conversation_id, + turn.id = %turn_context.sub_id, + model = %turn_context.model_info.slug, + ); + let handle = tokio::spawn( + async move { + let ctx_for_finish = Arc::clone(&ctx); + let last_agent_message = task_for_run + .run( + Arc::clone(&session_ctx), + ctx, + input, + task_cancellation_token.child_token(), + ) + .await; + let sess = session_ctx.clone_session(); + sess.flush_rollout().await; + if !task_cancellation_token.is_cancelled() { + // Emit completion uniformly from spawn site so all tasks share the same lifecycle. + sess.on_task_finished(Arc::clone(&ctx_for_finish), last_agent_message) + .await; + } + done_clone.notify_waiters(); + } + .instrument(task_span), + ); let timer = turn_context .session_telemetry .start_timer(TURN_E2E_DURATION_METRIC, &[]) .ok(); - let running_task = RunningTask { done, handle: Arc::new(AbortOnDropHandle::new(handle)), @@ -255,19 +263,44 @@ impl Session { _timer: timer, }; turn.add_task(running_task); - *active = Some(turn); } - pub(crate) async fn ensure_task_for_queued_response_items(self: &Arc) { - if !self.has_queued_response_items_for_next_turn().await { + /// Starts a regular turn when the session is idle and pending work is waiting. + /// + /// Pending work currently includes queued next-turn items and mailbox mail marked with + /// `trigger_turn`. + /// + /// This helper generates a fresh sub-id for the synthetic turn before delegating to the + /// explicit-sub-id variant. + pub(crate) async fn maybe_start_turn_for_pending_work(self: &Arc) { + self.maybe_start_turn_for_pending_work_with_sub_id(uuid::Uuid::new_v4().to_string()) + .await; + } + + /// Starts a regular turn with the provided sub-id when pending work should wake an idle + /// session. + /// + /// The turn is created only when there are queued next-turn items or mailbox mail marked with + /// `trigger_turn`, and only if the session is currently idle. + pub(crate) async fn maybe_start_turn_for_pending_work_with_sub_id( + self: &Arc, + sub_id: String, + ) { + if !self.has_queued_response_items_for_next_turn().await + && !self.has_trigger_turn_mailbox_items().await + { return; } - if self.active_turn.lock().await.is_some() { - return; + { + let mut active_turn = self.active_turn.lock().await; + if active_turn.is_some() { + return; + } + *active_turn = Some(ActiveTurn::default()); } - let turn_context = self.new_default_turn().await; + let turn_context = self.new_default_turn_with_sub_id(sub_id).await; self.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) .await; self.start_task(turn_context, Vec::new(), RegularTask::new()) @@ -284,7 +317,7 @@ impl Session { active_turn.clear_pending().await; } if reason == TurnAbortReason::Interrupted { - self.ensure_task_for_queued_response_items().await; + self.maybe_start_turn_for_pending_work().await; } } @@ -297,24 +330,31 @@ impl Session { .turn_metadata_state .cancel_git_enrichment_task(); - let mut active = self.active_turn.lock().await; let mut pending_input = Vec::::new(); let mut should_clear_active_turn = false; let mut token_usage_at_turn_start = None; let mut turn_tool_calls = 0_u64; - if let Some(at) = active.as_mut() - && at.remove_task(&turn_context.sub_id) - { - let mut ts = at.turn_state.lock().await; + let turn_state = { + let mut active = self.active_turn.lock().await; + if let Some(at) = active.as_mut() + && at.remove_task(&turn_context.sub_id) + { + should_clear_active_turn = true; + let turn_state = Arc::clone(&at.turn_state); + if should_clear_active_turn { + *active = None; + } + Some(turn_state) + } else { + None + } + }; + if let Some(turn_state) = turn_state { + let mut ts = turn_state.lock().await; pending_input = ts.take_pending_input(); turn_tool_calls = ts.tool_calls; token_usage_at_turn_start = Some(ts.token_usage_at_turn_start.clone()); - should_clear_active_turn = true; } - if should_clear_active_turn { - *active = None; - } - drop(active); if !pending_input.is_empty() { for pending_input_item in pending_input { match inspect_pending_input(self, &turn_context, pending_input_item).await { @@ -413,6 +453,15 @@ impl Session { last_agent_message, }); self.send_event(turn_context.as_ref(), event).await; + + if should_clear_active_turn { + let session = Arc::clone(self); + let _scheduler = tokio::task::spawn_blocking(move || { + tokio::runtime::Handle::current().block_on(async move { + session.maybe_start_turn_for_pending_work().await; + }); + }); + } } async fn take_active_turn(&self) -> Option { diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index b90717e715..25f994c866 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -185,14 +185,9 @@ pub(crate) async fn execute_user_shell_command( tx_event: session.get_tx_event(), }); - let exec_result = execute_exec_request( - exec_env, - &sandbox_policy, - stdout_stream, - /*after_spawn*/ None, - ) - .or_cancel(&cancellation_token) - .await; + let exec_result = execute_exec_request(exec_env, stdout_stream, /*after_spawn*/ None) + .or_cancel(&cancellation_token) + .await; match exec_result { Err(CancelErr::Cancelled) => { diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 5fa1881ffa..97370ce41e 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -3,10 +3,12 @@ //! In core, "user turns" are detected by scanning `ResponseItem::Message` items and //! interpreting them via `event_mapping::parse_turn_item(...)`. +use crate::context_manager::is_user_turn_boundary; use crate::event_mapping; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; /// Return the indices of user message boundaries in a rollout. @@ -40,6 +42,52 @@ pub(crate) fn user_message_positions_in_rollout(items: &[RolloutItem]) -> Vec Vec { + let mut rollback_turn_positions = Vec::new(); + let mut fork_turn_positions = Vec::new(); + for (idx, item) in items.iter().enumerate() { + match item { + RolloutItem::ResponseItem(item) => { + if is_user_turn_boundary(item) { + rollback_turn_positions.push(idx); + } + if is_real_user_message_boundary(item) || is_trigger_turn_boundary(item) { + fork_turn_positions.push(idx); + } + } + RolloutItem::EventMsg(EventMsg::ThreadRolledBack(rollback)) => { + let num_turns = usize::try_from(rollback.num_turns).unwrap_or(usize::MAX); + if num_turns == 0 { + continue; + } + let Some(rollback_start_idx) = rollback_turn_positions + .len() + .checked_sub(num_turns) + .map(|rollback_start| rollback_turn_positions[rollback_start]) + .or_else(|| rollback_turn_positions.first().copied()) + else { + continue; + }; + let new_rollback_len = rollback_turn_positions.len().saturating_sub(num_turns); + rollback_turn_positions.truncate(new_rollback_len); + fork_turn_positions.retain(|position| *position < rollback_start_idx); + } + _ => {} + } + } + fork_turn_positions +} + /// Return a prefix of `items` obtained by cutting strictly before the nth user message. /// /// The boundary index is 0-based from the start of `items` (so `n_from_start = 0` returns @@ -68,6 +116,43 @@ pub(crate) fn truncate_rollout_before_nth_user_message_from_start( items[..cut_idx].to_vec() } +/// Return a suffix of `items` that keeps the last `n_from_end` fork turns. +/// +/// If fewer than or equal to `n_from_end` fork turns exist, this returns the full rollout. +pub(crate) fn truncate_rollout_to_last_n_fork_turns( + items: &[RolloutItem], + n_from_end: usize, +) -> Vec { + if n_from_end == 0 { + return Vec::new(); + } + + let fork_turn_positions = fork_turn_positions_in_rollout(items); + if fork_turn_positions.len() <= n_from_end { + return items.to_vec(); + } + + let keep_idx = fork_turn_positions[fork_turn_positions.len() - n_from_end]; + items[keep_idx..].to_vec() +} + +fn is_real_user_message_boundary(item: &ResponseItem) -> bool { + matches!( + event_mapping::parse_turn_item(item), + Some(TurnItem::UserMessage(_)) + ) +} + +fn is_trigger_turn_boundary(item: &ResponseItem) -> bool { + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + + role == "assistant" + && InterAgentCommunication::from_message_content(content) + .is_some_and(|communication| communication.trigger_turn) +} + #[cfg(test)] #[path = "thread_rollout_truncation_tests.rs"] mod tests; diff --git a/codex-rs/core/src/thread_rollout_truncation_tests.rs b/codex-rs/core/src/thread_rollout_truncation_tests.rs index d251b722d9..d1380b7d45 100644 --- a/codex-rs/core/src/thread_rollout_truncation_tests.rs +++ b/codex-rs/core/src/thread_rollout_truncation_tests.rs @@ -1,7 +1,9 @@ use super::*; use crate::codex::make_session_and_context; +use codex_protocol::AgentPath; use codex_protocol::models::ContentItem; use codex_protocol::models::ReasoningItemReasoningSummary; +use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::ThreadRolledBackEvent; use pretty_assertions::assert_eq; @@ -29,6 +31,17 @@ fn assistant_msg(text: &str) -> ResponseItem { } } +fn inter_agent_msg(text: &str, trigger_turn: bool) -> ResponseItem { + let communication = InterAgentCommunication::new( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + Vec::new(), + text.to_string(), + trigger_turn, + ); + communication.to_response_input_item().into() +} + #[test] fn truncates_rollout_from_start_before_nth_user_only() { let items = [ @@ -157,3 +170,149 @@ async fn ignores_session_prefix_messages_when_truncating_rollout_from_start() { serde_json::to_value(&expected).unwrap() ); } + +#[test] +fn truncates_rollout_to_last_n_fork_turns_counts_trigger_turn_messages() { + let rollout = vec![ + RolloutItem::ResponseItem(user_msg("u1")), + RolloutItem::ResponseItem(assistant_msg("a1")), + RolloutItem::ResponseItem(inter_agent_msg( + "queued message", + /*trigger_turn*/ false, + )), + RolloutItem::ResponseItem(assistant_msg("a2")), + RolloutItem::ResponseItem(inter_agent_msg( + "triggered task", + /*trigger_turn*/ true, + )), + RolloutItem::ResponseItem(assistant_msg("a3")), + RolloutItem::ResponseItem(user_msg("u2")), + RolloutItem::ResponseItem(assistant_msg("a4")), + ]; + + let truncated = truncate_rollout_to_last_n_fork_turns(&rollout, /*n_from_end*/ 2); + let expected = rollout[4..].to_vec(); + + assert_eq!( + serde_json::to_value(&truncated).unwrap(), + serde_json::to_value(&expected).unwrap() + ); +} + +#[test] +fn truncates_rollout_to_last_n_fork_turns_applies_thread_rollback_markers() { + let rollout = vec![ + RolloutItem::ResponseItem(user_msg("u1")), + RolloutItem::ResponseItem(assistant_msg("a1")), + RolloutItem::ResponseItem(inter_agent_msg( + "triggered task", + /*trigger_turn*/ true, + )), + RolloutItem::ResponseItem(assistant_msg("a2")), + RolloutItem::EventMsg(EventMsg::ThreadRolledBack(ThreadRolledBackEvent { + num_turns: 1, + })), + RolloutItem::ResponseItem(user_msg("u2")), + RolloutItem::ResponseItem(assistant_msg("a3")), + ]; + + let truncated = truncate_rollout_to_last_n_fork_turns(&rollout, /*n_from_end*/ 2); + + assert_eq!( + serde_json::to_value(&truncated).unwrap(), + serde_json::to_value(&rollout).unwrap() + ); +} + +#[test] +fn fork_turn_positions_ignore_zero_turn_rollback_markers() { + let rollout = vec![ + RolloutItem::ResponseItem(user_msg("u1")), + RolloutItem::ResponseItem(inter_agent_msg( + "triggered task", + /*trigger_turn*/ true, + )), + RolloutItem::EventMsg(EventMsg::ThreadRolledBack(ThreadRolledBackEvent { + num_turns: 0, + })), + RolloutItem::ResponseItem(user_msg("u2")), + ]; + + assert_eq!(fork_turn_positions_in_rollout(&rollout), vec![0, 1, 3]); +} + +#[test] +fn truncates_rollout_to_last_n_fork_turns_discards_trigger_boundaries_in_rolled_back_suffix() { + let rollout = vec![ + RolloutItem::ResponseItem(user_msg("u1")), + RolloutItem::ResponseItem(user_msg("u2")), + RolloutItem::ResponseItem(inter_agent_msg( + "triggered task", + /*trigger_turn*/ true, + )), + RolloutItem::ResponseItem(assistant_msg("a1")), + RolloutItem::EventMsg(EventMsg::ThreadRolledBack(ThreadRolledBackEvent { + num_turns: 1, + })), + RolloutItem::ResponseItem(user_msg("u3")), + RolloutItem::ResponseItem(assistant_msg("a2")), + ]; + + let truncated = truncate_rollout_to_last_n_fork_turns(&rollout, /*n_from_end*/ 2); + + let expected = rollout[1..].to_vec(); + + assert_eq!( + serde_json::to_value(&truncated).unwrap(), + serde_json::to_value(&expected).unwrap() + ); +} + +#[test] +fn truncates_rollout_to_last_n_fork_turns_discards_rolled_back_assistant_instruction_turns() { + let rollout = vec![ + RolloutItem::ResponseItem(user_msg("u1")), + RolloutItem::ResponseItem(assistant_msg("a1")), + RolloutItem::ResponseItem(inter_agent_msg( + "triggered task 1", + /*trigger_turn*/ true, + )), + RolloutItem::ResponseItem(assistant_msg("a2")), + RolloutItem::EventMsg(EventMsg::ThreadRolledBack(ThreadRolledBackEvent { + num_turns: 1, + })), + RolloutItem::ResponseItem(inter_agent_msg( + "triggered task 2", + /*trigger_turn*/ true, + )), + RolloutItem::ResponseItem(assistant_msg("a3")), + ]; + + let truncated = truncate_rollout_to_last_n_fork_turns(&rollout, /*n_from_end*/ 1); + let expected = rollout[5..].to_vec(); + + assert_eq!( + serde_json::to_value(&truncated).unwrap(), + serde_json::to_value(&expected).unwrap() + ); +} + +#[test] +fn truncates_rollout_to_last_n_fork_turns_keeps_full_rollout_when_n_is_large() { + let rollout = vec![ + RolloutItem::ResponseItem(user_msg("u1")), + RolloutItem::ResponseItem(assistant_msg("a1")), + RolloutItem::ResponseItem(inter_agent_msg( + "triggered task", + /*trigger_turn*/ true, + )), + RolloutItem::ResponseItem(assistant_msg("a2")), + ]; + + let truncated = truncate_rollout_to_last_n_fork_turns(&rollout, /*n_from_end*/ 10); + + assert_eq!( + serde_json::to_value(&truncated).unwrap(), + serde_json::to_value(&rollout).unwrap() + ); +} diff --git a/codex-rs/core/src/tools/code_mode/mod.rs b/codex-rs/core/src/tools/code_mode/mod.rs index c8f16ca602..45e67271f0 100644 --- a/codex-rs/core/src/tools/code_mode/mod.rs +++ b/codex-rs/core/src/tools/code_mode/mod.rs @@ -14,7 +14,6 @@ use codex_protocol::models::ResponseInputItem; use serde_json::Value as JsonValue; use tokio_util::sync::CancellationToken; -use crate::client_common::tools::ToolSpec; use crate::codex::Session; use crate::codex::TurnContext; use crate::function_tool::FunctionCallError; @@ -28,6 +27,7 @@ use crate::tools::router::ToolCallSource; use crate::tools::router::ToolRouterParams; use crate::unified_exec::resolve_max_tokens; use codex_features::Feature; +use codex_tools::ToolSpec; use codex_tools::tool_spec_to_code_mode_tool_definition; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::formatted_truncate_text_content_items_with_policy; diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index 58d040cbe6..7c486640ee 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -1,4 +1,3 @@ -use crate::client_common::tools::ToolSearchOutputTool; use crate::codex::Session; use crate::codex::TurnContext; use crate::tools::TELEMETRY_PREVIEW_MAX_BYTES; @@ -14,6 +13,7 @@ use codex_protocol::models::ResponseInputItem; use codex_protocol::models::SearchToolCallParams; use codex_protocol::models::ShellToolCallParams; use codex_protocol::models::function_call_output_content_items_to_text; +use codex_tools::ToolSearchOutputTool; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::formatted_truncate_text; use codex_utils_string::take_bytes_at_char_boundary; diff --git a/codex-rs/core/src/tools/context_tests.rs b/codex-rs/core/src/tools/context_tests.rs index 54bf2ec75b..f7641a1db5 100644 --- a/codex-rs/core/src/tools/context_tests.rs +++ b/codex-rs/core/src/tools/context_tests.rs @@ -142,12 +142,12 @@ fn tool_search_payloads_roundtrip_as_tool_search_outputs() { }; let response = ToolSearchOutput { tools: vec![ToolSearchOutputTool::Function( - crate::client_common::tools::ResponsesApiTool { + codex_tools::ResponsesApiTool { name: "create_event".to_string(), description: String::new(), strict: false, defer_loading: Some(true), - parameters: crate::tools::spec::JsonSchema::Object { + parameters: codex_tools::JsonSchema::Object { properties: Default::default(), required: None, additional_properties: None, diff --git a/codex-rs/core/src/tools/discoverable.rs b/codex-rs/core/src/tools/discoverable.rs deleted file mode 100644 index e9403918e4..0000000000 --- a/codex-rs/core/src/tools/discoverable.rs +++ /dev/null @@ -1,134 +0,0 @@ -use crate::plugins::PluginCapabilitySummary; -use codex_app_server_protocol::AppInfo; -use serde::Deserialize; -use serde::Serialize; - -const TUI_CLIENT_NAME: &str = "codex-tui"; - -#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub(crate) enum DiscoverableToolType { - Connector, - Plugin, -} - -impl DiscoverableToolType { - pub(crate) fn as_str(self) -> &'static str { - match self { - Self::Connector => "connector", - Self::Plugin => "plugin", - } - } -} - -#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub(crate) enum DiscoverableToolAction { - Install, - Enable, -} - -impl DiscoverableToolAction { - pub(crate) fn as_str(self) -> &'static str { - match self { - Self::Install => "install", - Self::Enable => "enable", - } - } -} - -#[derive(Clone, Debug, PartialEq)] -pub(crate) enum DiscoverableTool { - Connector(Box), - Plugin(Box), -} - -impl DiscoverableTool { - pub(crate) fn tool_type(&self) -> DiscoverableToolType { - match self { - Self::Connector(_) => DiscoverableToolType::Connector, - Self::Plugin(_) => DiscoverableToolType::Plugin, - } - } - - pub(crate) fn id(&self) -> &str { - match self { - Self::Connector(connector) => connector.id.as_str(), - Self::Plugin(plugin) => plugin.id.as_str(), - } - } - - pub(crate) fn name(&self) -> &str { - match self { - Self::Connector(connector) => connector.name.as_str(), - Self::Plugin(plugin) => plugin.name.as_str(), - } - } - - pub(crate) fn description(&self) -> Option<&str> { - match self { - Self::Connector(connector) => connector.description.as_deref(), - Self::Plugin(plugin) => plugin.description.as_deref(), - } - } - - pub(crate) fn install_url(&self) -> Option<&str> { - match self { - Self::Connector(connector) => connector.install_url.as_deref(), - Self::Plugin(_) => None, - } - } -} - -impl From for DiscoverableTool { - fn from(value: AppInfo) -> Self { - Self::Connector(Box::new(value)) - } -} - -impl From for DiscoverableTool { - fn from(value: DiscoverablePluginInfo) -> Self { - Self::Plugin(Box::new(value)) - } -} - -pub(crate) fn filter_tool_suggest_discoverable_tools_for_client( - discoverable_tools: Vec, - app_server_client_name: Option<&str>, -) -> Vec { - if app_server_client_name != Some(TUI_CLIENT_NAME) { - return discoverable_tools; - } - - discoverable_tools - .into_iter() - .filter(|tool| !matches!(tool, DiscoverableTool::Plugin(_))) - .collect() -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct DiscoverablePluginInfo { - pub(crate) id: String, - pub(crate) name: String, - pub(crate) description: Option, - pub(crate) has_skills: bool, - pub(crate) mcp_server_names: Vec, - pub(crate) app_connector_ids: Vec, -} - -impl From for DiscoverablePluginInfo { - fn from(value: PluginCapabilitySummary) -> Self { - Self { - id: value.config_name, - name: value.display_name, - description: value.description, - has_skills: value.has_skills, - mcp_server_names: value.mcp_server_names, - app_connector_ids: value - .app_connector_ids - .into_iter() - .map(|connector_id| connector_id.0) - .collect(), - } - } -} diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 8bdaea059d..801cff56f3 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -1,13 +1,8 @@ -use std::collections::BTreeMap; use std::path::Path; use crate::apply_patch; use crate::apply_patch::InternalApplyPatchInvocation; use crate::apply_patch::convert_apply_patch_to_protocol; -use crate::client_common::tools::FreeformTool; -use crate::client_common::tools::FreeformToolFormat; -use crate::client_common::tools::ResponsesApiTool; -use crate::client_common::tools::ToolSpec; use crate::codex::Session; use crate::codex::TurnContext; use crate::function_tool::FunctionCallError; @@ -26,8 +21,6 @@ use crate::tools::registry::ToolKind; use crate::tools::runtimes::apply_patch::ApplyPatchRequest; use crate::tools::runtimes::apply_patch::ApplyPatchRuntime; use crate::tools::sandboxing::ToolCtx; -use crate::tools::spec::ApplyPatchToolArgs; -use crate::tools::spec::JsonSchema; use async_trait::async_trait; use codex_apply_patch::ApplyPatchAction; use codex_apply_patch::ApplyPatchFileChange; @@ -36,14 +29,13 @@ use codex_protocol::models::PermissionProfile; use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy; use codex_sandboxing::policy_transforms::merge_permission_profiles; use codex_sandboxing::policy_transforms::normalize_additional_permissions; +use codex_tools::ApplyPatchToolArgs; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::BTreeSet; use std::sync::Arc; pub struct ApplyPatchHandler; -const APPLY_PATCH_LARK_GRAMMAR: &str = include_str!("tool_apply_patch.lark"); - fn file_paths_for_action(action: &ApplyPatchAction) -> Vec { let mut keys = Vec::new(); let cwd = action.cwd.as_path(); @@ -356,112 +348,6 @@ pub(crate) async fn intercept_apply_patch( } } -/// Returns a custom tool that can be used to edit files. Well-suited for GPT-5 models -/// https://platform.openai.com/docs/guides/function-calling#custom-tools -pub(crate) fn create_apply_patch_freeform_tool() -> ToolSpec { - ToolSpec::Freeform(FreeformTool { - name: "apply_patch".to_string(), - description: "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.".to_string(), - format: FreeformToolFormat { - r#type: "grammar".to_string(), - syntax: "lark".to_string(), - definition: APPLY_PATCH_LARK_GRAMMAR.to_string(), - }, - }) -} - -/// Returns a json tool that can be used to edit files. Should only be used with gpt-oss models -pub(crate) fn create_apply_patch_json_tool() -> ToolSpec { - let mut properties = BTreeMap::new(); - properties.insert( - "input".to_string(), - JsonSchema::String { - description: Some(r#"The entire contents of the apply_patch command"#.to_string()), - }, - ); - - ToolSpec::Function(ResponsesApiTool { - name: "apply_patch".to_string(), - description: r#"Use the `apply_patch` tool to edit files. -Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope: - -*** Begin Patch -[ one or more file sections ] -*** End Patch - -Within that envelope, you get a sequence of file operations. -You MUST include a header to specify the action you are taking. -Each operation starts with one of three headers: - -*** Add File: - create a new file. Every following line is a + line (the initial contents). -*** Delete File: - remove an existing file. Nothing follows. -*** Update File: - patch an existing file in place (optionally with a rename). - -May be immediately followed by *** Move to: if you want to rename the file. -Then one or more “hunks”, each introduced by @@ (optionally followed by a hunk header). -Within a hunk each line starts with: - -For instructions on [context_before] and [context_after]: -- By default, show 3 lines of code immediately above and 3 lines immediately below each change. If a change is within 3 lines of a previous change, do NOT duplicate the first change’s [context_after] lines in the second change’s [context_before] lines. -- If 3 lines of context is insufficient to uniquely identify the snippet of code within the file, use the @@ operator to indicate the class or function to which the snippet belongs. For instance, we might have: -@@ class BaseClass -[3 lines of pre-context] -- [old_code] -+ [new_code] -[3 lines of post-context] - -- If a code block is repeated so many times in a class or function such that even a single `@@` statement and 3 lines of context cannot uniquely identify the snippet of code, you can use multiple `@@` statements to jump to the right context. For instance: - -@@ class BaseClass -@@ def method(): -[3 lines of pre-context] -- [old_code] -+ [new_code] -[3 lines of post-context] - -The full grammar definition is below: -Patch := Begin { FileOp } End -Begin := "*** Begin Patch" NEWLINE -End := "*** End Patch" NEWLINE -FileOp := AddFile | DeleteFile | UpdateFile -AddFile := "*** Add File: " path NEWLINE { "+" line NEWLINE } -DeleteFile := "*** Delete File: " path NEWLINE -UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] { Hunk } -MoveTo := "*** Move to: " newPath NEWLINE -Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ] -HunkLine := (" " | "-" | "+") text NEWLINE - -A full patch can combine several operations: - -*** Begin Patch -*** Add File: hello.txt -+Hello world -*** Update File: src/app.py -*** Move to: src/main.py -@@ def greet(): --print("Hi") -+print("Hello, world!") -*** Delete File: obsolete.txt -*** End Patch - -It is important to remember: - -- You must include a header with your intended action (Add/Delete/Update) -- You must prefix new lines with `+` even when creating a new file -- File references can only be relative, NEVER ABSOLUTE. -"# - .to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::Object { - properties, - required: Some(vec!["input".to_string()]), - additional_properties: Some(false.into()), - }, - output_schema: None, - }) -} - #[cfg(test)] #[path = "apply_patch_tests.rs"] mod tests; diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 86f2175be6..f0a62b8c14 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -22,7 +22,6 @@ use codex_sandboxing::policy_transforms::intersect_permission_profiles; use codex_sandboxing::policy_transforms::merge_permission_profiles; use codex_sandboxing::policy_transforms::normalize_additional_permissions; use codex_utils_absolute_path::AbsolutePathBufGuard; -pub use plan::PLAN_TOOL; use serde::Deserialize; use serde_json::Value; use std::path::Path; @@ -44,16 +43,11 @@ pub use mcp::McpHandler; pub use mcp_resource::McpResourceHandler; pub use plan::PlanHandler; pub use request_permissions::RequestPermissionsHandler; -pub(crate) use request_permissions::request_permissions_tool_description; pub use request_user_input::RequestUserInputHandler; -pub(crate) use request_user_input::request_user_input_tool_description; pub use shell::ShellCommandHandler; pub use shell::ShellHandler; pub use test_sync::TestSyncHandler; -pub(crate) use tool_search::DEFAULT_LIMIT as TOOL_SEARCH_DEFAULT_LIMIT; -pub(crate) use tool_search::TOOL_SEARCH_TOOL_NAME; pub use tool_search::ToolSearchHandler; -pub(crate) use tool_suggest::TOOL_SUGGEST_TOOL_NAME; pub use tool_suggest::ToolSuggestHandler; pub use unified_exec::UnifiedExecHandler; pub use view_image::ViewImageHandler; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 47ea42d516..739675bc2b 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -1,4 +1,5 @@ use super::*; +use crate::agent::control::SpawnAgentForkMode; use crate::agent::control::SpawnAgentOptions; use crate::agent::control::render_input_preview; use crate::agent::role::DEFAULT_ROLE_NAME; @@ -90,6 +91,7 @@ impl ToolHandler for Handler { )?), SpawnAgentOptions { fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), + fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory), }, ) .await diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 8006ee2204..c971346d75 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -16,7 +16,10 @@ use crate::protocol::Op; use crate::protocol::SandboxPolicy; use crate::protocol::SessionSource; use crate::protocol::SubAgentSource; +use crate::protocol::TurnAbortReason; +use crate::protocol::TurnAbortedEvent; use crate::protocol::TurnCompleteEvent; +use crate::session_prefix::format_subagent_notification_message; use crate::state::TaskKind; use crate::tasks::SessionTask; use crate::tasks::SessionTaskContext; @@ -356,6 +359,42 @@ async fn multi_agent_v2_spawn_requires_task_name() { assert!(message.contains("missing field `task_name`")); } +#[tokio::test] +async fn multi_agent_v2_spawn_rejects_legacy_items_field() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + turn.config = Arc::new(config); + + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "items": [{"type": "text", "text": "inspect this repo"}], + "task_name": "worker" + })), + ); + let Err(err) = SpawnAgentHandlerV2.handle(invocation).await else { + panic!("legacy items field should be rejected"); + }; + let FunctionCallError::RespondToModel(message) = err else { + panic!("legacy items field should surface as a model-facing error"); + }; + assert!(message.contains("unknown field `items`")); +} + #[tokio::test] async fn spawn_agent_errors_when_manager_dropped() { let (session, turn) = make_session_and_context().await; @@ -457,7 +496,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat "send_message", function_payload(json!({ "target": "test_process", - "items": [{"type": "text", "text": "continue"}] + "message": "continue" })), )) .await @@ -477,6 +516,123 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat })); } +#[tokio::test] +async fn multi_agent_v2_spawn_rejects_legacy_fork_context() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + turn.config = Arc::new(config); + + let err = SpawnAgentHandlerV2 + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "task_name": "worker", + "fork_context": true + })), + )) + .await + .expect_err("legacy fork_context should be rejected"); + + assert_eq!( + err, + FunctionCallError::RespondToModel( + "fork_context is not supported in MultiAgentV2; use fork_turns instead".to_string() + ) + ); +} + +#[tokio::test] +async fn multi_agent_v2_spawn_rejects_invalid_fork_turns_string() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + turn.config = Arc::new(config); + + let err = SpawnAgentHandlerV2 + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "task_name": "worker", + "fork_turns": "banana" + })), + )) + .await + .expect_err("invalid fork_turns should be rejected"); + + assert_eq!( + err, + FunctionCallError::RespondToModel( + "fork_turns must be `none`, `all`, or a positive integer string".to_string() + ) + ); +} + +#[tokio::test] +async fn multi_agent_v2_spawn_rejects_zero_fork_turns() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + turn.config = Arc::new(config); + + let err = SpawnAgentHandlerV2 + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "task_name": "worker", + "fork_turns": "0" + })), + )) + .await + .expect_err("zero turn count should be rejected"); + + assert_eq!( + err, + FunctionCallError::RespondToModel( + "fork_turns must be `none`, `all`, or a positive integer string".to_string() + ) + ); +} + #[tokio::test] async fn multi_agent_v2_send_message_accepts_root_target_from_child() { let (mut session, mut turn) = make_session_and_context().await; @@ -533,7 +689,7 @@ async fn multi_agent_v2_send_message_accepts_root_target_from_child() { "send_message", function_payload(json!({ "target": "/root", - "items": [{"type": "text", "text": "done"}] + "message": "done" })), )) .await @@ -553,6 +709,86 @@ async fn multi_agent_v2_send_message_accepts_root_target_from_child() { })); } +#[tokio::test] +async fn multi_agent_v2_assign_task_rejects_root_target_from_child() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + turn.config = Arc::new(config); + + let child_path = AgentPath::try_from("/root/worker").expect("agent path"); + let child_thread_id = session + .services + .agent_control + .spawn_agent_with_metadata( + (*turn.config).clone(), + vec![UserInput::Text { + text: "inspect this repo".to_string(), + text_elements: Vec::new(), + }] + .into(), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root.thread_id, + depth: 1, + agent_path: Some(child_path.clone()), + agent_nickname: None, + agent_role: None, + })), + crate::agent::control::SpawnAgentOptions::default(), + ) + .await + .expect("worker spawn should succeed") + .thread_id; + session.conversation_id = child_thread_id; + turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root.thread_id, + depth: 1, + agent_path: Some(child_path), + agent_nickname: None, + agent_role: None, + }); + + let err = AssignTaskHandlerV2 + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "assign_task", + function_payload(json!({ + "target": "/root", + "message": "run this", + "interrupt": true + })), + )) + .await + .expect_err("assign_task should reject the root target"); + + assert_eq!( + err, + FunctionCallError::RespondToModel("Tasks can't be assigned to the root agent".to_string()) + ); + let root_ops = manager + .captured_ops() + .into_iter() + .filter_map(|(id, op)| (id == root.thread_id).then_some(op)) + .collect::>(); + assert!(!root_ops.iter().any(|op| matches!(op, Op::Interrupt))); + assert!( + !root_ops + .iter() + .any(|op| matches!(op, Op::InterAgentCommunication { .. })) + ); +} + #[tokio::test] async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_message() { let (mut session, mut turn) = make_session_and_context().await; @@ -796,7 +1032,7 @@ async fn multi_agent_v2_list_agents_omits_closed_agents() { } #[tokio::test] -async fn multi_agent_v2_send_message_rejects_structured_items() { +async fn multi_agent_v2_send_message_rejects_legacy_items_field() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); let root = manager @@ -843,18 +1079,16 @@ async fn multi_agent_v2_send_message_rejects_structured_items() { ); let Err(err) = SendMessageHandlerV2.handle(invocation).await else { - panic!("structured items should be rejected in v2"); + panic!("legacy items field should be rejected in v2"); }; - assert_eq!( - err, - FunctionCallError::RespondToModel( - "send_message only supports text content in MultiAgentV2 for now".to_string() - ) - ); + let FunctionCallError::RespondToModel(message) = err else { + panic!("legacy items field should surface as a model-facing error"); + }; + assert!(message.contains("unknown field `items`")); } #[tokio::test] -async fn multi_agent_v2_send_message_interrupts_busy_child_without_triggering_turn() { +async fn multi_agent_v2_send_message_rejects_interrupt_parameter() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); let root = manager @@ -887,112 +1121,43 @@ async fn multi_agent_v2_send_message_interrupts_busy_child_without_triggering_tu .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") .await .expect("worker should resolve"); - let thread = manager - .get_thread(agent_id) - .await - .expect("worker thread should exist"); - let active_turn = thread.codex.session.new_default_turn().await; - thread - .codex - .session - .spawn_task( - Arc::clone(&active_turn), - vec![UserInput::Text { - text: "working".to_string(), - text_elements: Vec::new(), - }], - NeverEndingTask, - ) - .await; + let invocation = invocation( + session, + turn, + "send_message", + function_payload(json!({ + "target": agent_id.to_string(), + "message": "continue", + "interrupt": true + })), + ); - SendMessageHandlerV2 - .handle(invocation( - session.clone(), - turn.clone(), - "send_message", - function_payload(json!({ - "target": agent_id.to_string(), - "items": [{"type": "text", "text": "continue"}], - "interrupt": true - })), - )) - .await - .expect("interrupting v2 send_message should succeed"); + let Err(err) = SendMessageHandlerV2.handle(invocation).await else { + panic!("send_message interrupt parameter should be rejected"); + }; + let FunctionCallError::RespondToModel(message) = err else { + panic!("expected model-facing parse error"); + }; + assert!(message.starts_with( + "failed to parse function arguments: unknown field `interrupt`, expected `target` or `message`" + )); let ops = manager.captured_ops(); let ops_for_agent: Vec<&Op> = ops .iter() .filter_map(|(id, op)| (*id == agent_id).then_some(op)) .collect(); - assert!(ops_for_agent.iter().any(|op| matches!(op, Op::Interrupt))); - assert!(ops_for_agent.iter().any(|op| { - matches!( - op, - Op::InterAgentCommunication { communication } - if communication.author == AgentPath::root() - && communication.recipient.as_str() == "/root/worker" - && communication.other_recipients.is_empty() - && communication.content == "continue" - && !communication.trigger_turn - ) - })); - - timeout(Duration::from_secs(5), async { - loop { - if !thread - .codex - .session - .has_queued_response_items_for_next_turn() - .await - { - tokio::time::sleep(Duration::from_millis(10)).await; - continue; - } - let history_items = thread - .codex - .session - .clone_history() - .await - .raw_items() - .to_vec(); - let saw_envelope = history_contains_inter_agent_communication( - &history_items, - &InterAgentCommunication::new( - AgentPath::root(), - AgentPath::try_from("/root/worker").expect("agent path"), - Vec::new(), - "continue".to_string(), - /*trigger_turn*/ false, - ), - ); - let saw_user_message = history_items.iter().any(|item| { - matches!( - item, - ResponseItem::Message { role, content, .. } - if role == "user" - && content.iter().any(|content_item| matches!( - content_item, - ContentItem::InputText { text } if text == "continue" - )) - ) - }); - if saw_envelope && !saw_user_message { - panic!("send_message should not materialize the envelope into history"); - } - if !saw_user_message { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("interrupting v2 send_message should queue the redirected message without a turn"); - - let _ = thread - .submit(Op::Shutdown {}) - .await - .expect("shutdown should submit"); + assert!(!ops_for_agent.iter().any(|op| matches!(op, Op::Interrupt))); + assert!(!ops_for_agent.iter().any(|op| matches!( + op, + Op::InterAgentCommunication { communication } + if communication.author == AgentPath::root() + && communication.recipient.as_str() == "/root/worker" + && communication.other_recipients.is_empty() + && communication.content == "continue" + && !communication.trigger_turn + ))); } #[tokio::test] @@ -1055,7 +1220,7 @@ async fn multi_agent_v2_assign_task_interrupts_busy_child_without_losing_message "assign_task", function_payload(json!({ "target": agent_id.to_string(), - "items": [{"type": "text", "text": "continue"}], + "message": "continue", "interrupt": true })), )) @@ -1124,6 +1289,263 @@ async fn multi_agent_v2_assign_task_interrupts_busy_child_without_losing_message .expect("shutdown should submit"); } +#[tokio::test] +async fn multi_agent_v2_assign_task_completion_notifies_parent_on_every_turn() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = turn.config.as_ref().clone(); + let _ = config.features.enable(Feature::MultiAgentV2); + turn.config = Arc::new(config); + let session = Arc::new(session); + let turn = Arc::new(turn); + + SpawnAgentHandlerV2 + .handle(invocation( + session.clone(), + turn.clone(), + "spawn_agent", + function_payload(json!({ + "message": "boot worker", + "task_name": "worker" + })), + )) + .await + .expect("spawn worker"); + let agent_id = session + .services + .agent_control + .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .await + .expect("worker should resolve"); + let thread = manager + .get_thread(agent_id) + .await + .expect("worker thread should exist"); + let worker_path = AgentPath::try_from("/root/worker").expect("worker path"); + + let first_turn = thread.codex.session.new_default_turn().await; + thread + .codex + .session + .send_event( + first_turn.as_ref(), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: first_turn.sub_id.clone(), + last_agent_message: Some("first done".to_string()), + }), + ) + .await; + + AssignTaskHandlerV2 + .handle(invocation( + session, + turn, + "assign_task", + function_payload(json!({ + "target": agent_id.to_string(), + "message": "continue", + })), + )) + .await + .expect("assign_task should succeed"); + + let second_turn = thread.codex.session.new_default_turn().await; + thread + .codex + .session + .send_event( + second_turn.as_ref(), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: second_turn.sub_id.clone(), + last_agent_message: Some("second done".to_string()), + }), + ) + .await; + + let first_notification = format_subagent_notification_message( + worker_path.as_str(), + &AgentStatus::Completed(Some("first done".to_string())), + ); + let second_notification = format_subagent_notification_message( + worker_path.as_str(), + &AgentStatus::Completed(Some("second done".to_string())), + ); + + let notifications = timeout(Duration::from_secs(5), async { + loop { + let notifications = manager + .captured_ops() + .into_iter() + .filter_map(|(id, op)| { + (id == root.thread_id) + .then_some(op) + .and_then(|op| match op { + Op::InterAgentCommunication { communication } + if communication.author == worker_path + && communication.recipient == AgentPath::root() + && communication.other_recipients.is_empty() + && !communication.trigger_turn => + { + Some(communication.content) + } + _ => None, + }) + }) + .collect::>(); + let first_count = notifications + .iter() + .filter(|message| **message == first_notification) + .count(); + let second_count = notifications + .iter() + .filter(|message| **message == second_notification) + .count(); + if first_count == 1 && second_count == 1 { + break notifications; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("parent should receive one completion notification per child turn"); + + assert_eq!(notifications.len(), 2); +} + +#[tokio::test] +async fn multi_agent_v2_assign_task_rejects_legacy_items_field() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = turn.config.as_ref().clone(); + let _ = config.features.enable(Feature::MultiAgentV2); + turn.config = Arc::new(config); + let session = Arc::new(session); + let turn = Arc::new(turn); + + SpawnAgentHandlerV2 + .handle(invocation( + session.clone(), + turn.clone(), + "spawn_agent", + function_payload(json!({ + "message": "boot worker", + "task_name": "worker" + })), + )) + .await + .expect("spawn worker"); + let agent_id = session + .services + .agent_control + .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .await + .expect("worker should resolve"); + let invocation = invocation( + session, + turn, + "assign_task", + function_payload(json!({ + "target": agent_id.to_string(), + "items": [{"type": "text", "text": "continue"}], + })), + ); + + let Err(err) = AssignTaskHandlerV2.handle(invocation).await else { + panic!("legacy items field should be rejected in v2"); + }; + let FunctionCallError::RespondToModel(message) = err else { + panic!("legacy items field should surface as a model-facing error"); + }; + assert!(message.contains("unknown field `items`")); +} + +#[tokio::test] +async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = turn.config.as_ref().clone(); + let _ = config.features.enable(Feature::MultiAgentV2); + turn.config = Arc::new(config); + let session = Arc::new(session); + let turn = Arc::new(turn); + + SpawnAgentHandlerV2 + .handle(invocation( + session.clone(), + turn.clone(), + "spawn_agent", + function_payload(json!({ + "message": "boot worker", + "task_name": "worker" + })), + )) + .await + .expect("spawn worker"); + let agent_id = session + .services + .agent_control + .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .await + .expect("worker should resolve"); + let thread = manager + .get_thread(agent_id) + .await + .expect("worker thread should exist"); + + let aborted_turn = thread.codex.session.new_default_turn().await; + thread + .codex + .session + .send_event( + aborted_turn.as_ref(), + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: Some(aborted_turn.sub_id.clone()), + reason: TurnAbortReason::Interrupted, + }), + ) + .await; + + let notifications = manager + .captured_ops() + .into_iter() + .filter_map(|(id, op)| { + (id == root.thread_id) + .then_some(op) + .and_then(|op| match op { + Op::InterAgentCommunication { communication } + if communication.author.as_str() == "/root/worker" + && communication.recipient == AgentPath::root() + && communication.other_recipients.is_empty() + && !communication.trigger_turn => + { + Some(communication.content) + } + _ => None, + }) + }) + .collect::>(); + + assert_eq!(notifications, Vec::::new()); +} + #[tokio::test] async fn multi_agent_v2_spawn_includes_agent_id_key_when_named() { let (mut session, mut turn) = make_session_and_context().await; @@ -1788,27 +2210,78 @@ async fn wait_agent_rejects_empty_targets() { } #[tokio::test] -async fn multi_agent_v2_wait_agent_accepts_targets_argument() { +async fn multi_agent_v2_wait_agent_accepts_timeout_only_argument() { let (mut session, mut turn) = make_session_and_context().await; - let target = ThreadId::new().to_string(); let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; let mut config = (*turn.config).clone(); config .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); turn.config = Arc::new(config); - let invocation = invocation( - Arc::new(session), - Arc::new(turn), - "wait_agent", - function_payload(json!({"targets": [target.clone()]})), - ); - let output = WaitAgentHandlerV2 - .handle(invocation) + let session = Arc::new(session); + let turn = Arc::new(turn); + + SpawnAgentHandlerV2 + .handle(invocation( + session.clone(), + turn.clone(), + "spawn_agent", + function_payload(json!({ + "message": "boot worker", + "task_name": "worker" + })), + )) .await - .expect("targets should be accepted in v2 mode"); + .expect("spawn worker"); + let agent_id = session + .services + .agent_control + .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .await + .expect("worker should resolve"); + let worker_path = session + .services + .agent_control + .get_agent_metadata(agent_id) + .expect("worker metadata") + .agent_path + .expect("worker path"); + + let wait_task = tokio::spawn({ + let session = session.clone(); + let turn = turn.clone(); + async move { + WaitAgentHandlerV2 + .handle(invocation( + session, + turn, + "wait_agent", + function_payload(json!({"timeout_ms": 1000})), + )) + .await + } + }); + tokio::task::yield_now().await; + + session.enqueue_mailbox_communication(InterAgentCommunication::new( + worker_path, + AgentPath::root(), + Vec::new(), + "hello from worker".to_string(), + /*trigger_turn*/ false, + )); + + let output = wait_task + .await + .expect("wait task should join") + .expect("timeout-only args should be accepted in v2 mode"); let (content, success) = expect_text_output(output); let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult = serde_json::from_str(&content).expect("wait_agent result should be json"); @@ -1983,7 +2456,7 @@ async fn wait_agent_returns_final_status_without_timeout() { } #[tokio::test] -async fn multi_agent_v2_wait_agent_returns_summary_for_named_targets() { +async fn multi_agent_v2_wait_agent_returns_summary_for_mailbox_activity() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); let root = manager @@ -2025,37 +2498,229 @@ async fn multi_agent_v2_wait_agent_returns_summary_for_named_targets() { ) .await .expect("relative path should resolve"); - let mut status_rx = manager - .agent_control() - .subscribe_status(agent_id) - .await - .expect("subscribe should succeed"); + let worker_path = session + .services + .agent_control + .get_agent_metadata(agent_id) + .expect("worker metadata") + .agent_path + .expect("worker path"); + let wait_task = tokio::spawn({ + let session = session.clone(); + let turn = turn.clone(); + async move { + WaitAgentHandlerV2 + .handle(invocation( + session, + turn, + "wait_agent", + function_payload(json!({"timeout_ms": 1000})), + )) + .await + } + }); + tokio::task::yield_now().await; - let child_thread = manager - .get_thread(agent_id) - .await - .expect("child should exist"); - let _ = child_thread - .submit(Op::Shutdown {}) - .await - .expect("shutdown should submit"); - let _ = timeout(Duration::from_secs(1), status_rx.changed()) - .await - .expect("shutdown status should arrive"); + session.enqueue_mailbox_communication(InterAgentCommunication::new( + worker_path, + AgentPath::root(), + Vec::new(), + "completed".to_string(), + /*trigger_turn*/ false, + )); - let wait_output = WaitAgentHandlerV2 + let wait_output = wait_task + .await + .expect("wait task should join") + .expect("wait_agent should succeed"); + let (content, success) = expect_text_output(wait_output); + let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult = + serde_json::from_str(&content).expect("wait_agent result should be json"); + assert_eq!( + result, + crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult { + message: "Wait completed.".to_string(), + timed_out: false, + } + ); + assert_eq!(success, None); +} + +#[tokio::test] +async fn multi_agent_v2_wait_agent_waits_for_new_mail_after_start() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + turn.config = Arc::new(config); + let session = Arc::new(session); + let turn = Arc::new(turn); + + SpawnAgentHandlerV2 .handle(invocation( - session, - turn, - "wait_agent", + session.clone(), + turn.clone(), + "spawn_agent", function_payload(json!({ - "targets": ["test_process"], - "timeout_ms": 1000 + "message": "boot worker", + "task_name": "worker" })), )) .await + .expect("spawn worker"); + let agent_id = session + .services + .agent_control + .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .await + .expect("worker should resolve"); + let worker_path = session + .services + .agent_control + .get_agent_metadata(agent_id) + .expect("worker metadata") + .agent_path + .expect("worker path"); + + session.enqueue_mailbox_communication(InterAgentCommunication::new( + worker_path.clone(), + AgentPath::root(), + Vec::new(), + "already queued".to_string(), + /*trigger_turn*/ false, + )); + + let wait_task = tokio::spawn({ + let session = session.clone(); + let turn = turn.clone(); + async move { + WaitAgentHandlerV2 + .handle(invocation( + session, + turn, + "wait_agent", + function_payload(json!({"timeout_ms": 1000})), + )) + .await + } + }); + tokio::task::yield_now().await; + tokio::time::sleep(Duration::from_millis(50)).await; + assert!( + !wait_task.is_finished(), + "mail already queued before wait should not wake wait_agent" + ); + + session.enqueue_mailbox_communication(InterAgentCommunication::new( + worker_path, + AgentPath::root(), + Vec::new(), + "new mail".to_string(), + /*trigger_turn*/ false, + )); + + let output = wait_task + .await + .expect("wait task should join") .expect("wait_agent should succeed"); - let (content, success) = expect_text_output(wait_output); + let (content, success) = expect_text_output(output); + let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult = + serde_json::from_str(&content).expect("wait_agent result should be json"); + assert_eq!( + result, + crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult { + message: "Wait completed.".to_string(), + timed_out: false, + } + ); + assert_eq!(success, None); +} + +#[tokio::test] +async fn multi_agent_v2_wait_agent_wakes_on_any_mailbox_notification() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + turn.config = Arc::new(config); + let session = Arc::new(session); + let turn = Arc::new(turn); + + for task_name in ["worker_a", "worker_b"] { + SpawnAgentHandlerV2 + .handle(invocation( + session.clone(), + turn.clone(), + "spawn_agent", + function_payload(json!({ + "message": format!("boot {task_name}"), + "task_name": task_name + })), + )) + .await + .expect("spawn worker"); + } + let worker_b_id = session + .services + .agent_control + .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker_b") + .await + .expect("worker_b should resolve"); + let worker_b_path = session + .services + .agent_control + .get_agent_metadata(worker_b_id) + .expect("worker_b metadata") + .agent_path + .expect("worker_b path"); + + let wait_task = tokio::spawn({ + let session = session.clone(); + let turn = turn.clone(); + async move { + WaitAgentHandlerV2 + .handle(invocation( + session, + turn, + "wait_agent", + function_payload(json!({"timeout_ms": 1000})), + )) + .await + } + }); + tokio::task::yield_now().await; + + session.enqueue_mailbox_communication(InterAgentCommunication::new( + worker_b_path, + AgentPath::root(), + Vec::new(), + "from worker b".to_string(), + /*trigger_turn*/ false, + )); + + let output = wait_task + .await + .expect("wait task should join") + .expect("wait_agent should succeed"); + let (content, success) = expect_text_output(output); let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult = serde_json::from_str(&content).expect("wait_agent result should be json"); assert_eq!( @@ -2072,41 +2737,73 @@ async fn multi_agent_v2_wait_agent_returns_summary_for_named_targets() { async fn multi_agent_v2_wait_agent_does_not_return_completed_content() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; let mut config = (*turn.config).clone(); config .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - turn.config = Arc::new(config.clone()); + turn.config = Arc::new(config); + let session = Arc::new(session); + let turn = Arc::new(turn); - let thread = manager.start_thread(config).await.expect("start thread"); - let agent_id = thread.thread_id; - let child_turn = thread.thread.codex.session.new_default_turn().await; - thread - .thread - .codex - .session - .send_event( - child_turn.as_ref(), - EventMsg::TurnComplete(TurnCompleteEvent { - turn_id: child_turn.sub_id.clone(), - last_agent_message: Some("sensitive child output".to_string()), - }), - ) - .await; - - let output = WaitAgentHandlerV2 + SpawnAgentHandlerV2 .handle(invocation( - Arc::new(session), - Arc::new(turn), - "wait_agent", + session.clone(), + turn.clone(), + "spawn_agent", function_payload(json!({ - "targets": [agent_id.to_string()], - "timeout_ms": 1000 + "message": "boot worker", + "task_name": "worker" })), )) .await + .expect("spawn worker"); + let agent_id = session + .services + .agent_control + .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .await + .expect("worker should resolve"); + let worker_path = session + .services + .agent_control + .get_agent_metadata(agent_id) + .expect("worker metadata") + .agent_path + .expect("worker path"); + let wait_task = tokio::spawn({ + let session = session.clone(); + let turn = turn.clone(); + async move { + WaitAgentHandlerV2 + .handle(invocation( + session, + turn, + "wait_agent", + function_payload(json!({"timeout_ms": 1000})), + )) + .await + } + }); + tokio::task::yield_now().await; + + session.enqueue_mailbox_communication(InterAgentCommunication::new( + worker_path, + AgentPath::root(), + Vec::new(), + "sensitive child output".to_string(), + /*trigger_turn*/ false, + )); + + let output = wait_task + .await + .expect("wait task should join") .expect("wait_agent should succeed"); let (content, success) = expect_text_output(output); let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult = diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs index c25f0918ed..e572ec0af1 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -2,9 +2,7 @@ use crate::agent::AgentStatus; use crate::agent::agent_resolver::resolve_agent_target; -use crate::agent::agent_resolver::resolve_agent_targets; use crate::agent::exceeds_thread_spawn_depth_limit; -use crate::codex::Session; use crate::function_tool::FunctionCallError; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; @@ -15,12 +13,10 @@ use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; use async_trait::async_trait; use codex_protocol::AgentPath; -use codex_protocol::ThreadId; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::CollabAgentInteractionBeginEvent; use codex_protocol::protocol::CollabAgentInteractionEndEvent; -use codex_protocol::protocol::CollabAgentRef; use codex_protocol::protocol::CollabAgentSpawnBeginEvent; use codex_protocol::protocol::CollabAgentSpawnEndEvent; use codex_protocol::protocol::CollabCloseBeginEvent; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/assign_task.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/assign_task.rs index e55b65b311..c55c25d05d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/assign_task.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/assign_task.rs @@ -1,6 +1,7 @@ +use super::message_tool::AssignTaskArgs; use super::message_tool::MessageDeliveryMode; use super::message_tool::MessageToolResult; -use super::message_tool::handle_message_tool; +use super::message_tool::handle_message_string_tool; use super::*; pub(crate) struct Handler; @@ -18,6 +19,15 @@ impl ToolHandler for Handler { } async fn handle(&self, invocation: ToolInvocation) -> Result { - handle_message_tool(invocation, MessageDeliveryMode::TriggerTurn).await + let arguments = function_arguments(invocation.payload.clone())?; + let args: AssignTaskArgs = parse_arguments(&arguments)?; + handle_message_string_tool( + invocation, + MessageDeliveryMode::TriggerTurn, + args.target, + args.message, + args.interrupt, + ) + .await } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs index 2fe7048233..2296ae5dd3 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs @@ -106,6 +106,7 @@ impl ToolHandler for Handler { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct CloseAgentArgs { target: String, } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs index e18547db92..b3b3f7160b 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs @@ -40,6 +40,7 @@ impl ToolHandler for Handler { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct ListAgentsArgs { path_prefix: Option, } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs index a61c01f6a1..62835a12ca 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -1,27 +1,18 @@ //! Shared argument parsing and dispatch for the v2 text-only agent messaging tools. //! -//! `send_message` and `assign_task` intentionally expose the same input shape and differ only in -//! whether the resulting `InterAgentCommunication` should wake the target immediately. +//! `send_message` and `assign_task` share the same submission path and differ only in whether the +//! resulting `InterAgentCommunication` should wake the target immediately. use super::*; -use crate::agent::control::render_input_preview; use codex_protocol::protocol::InterAgentCommunication; -#[derive(Clone, Copy)] +#[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum MessageDeliveryMode { QueueOnly, TriggerTurn, } impl MessageDeliveryMode { - /// Returns the model-visible error message for non-text inputs. - fn unsupported_items_error(self) -> &'static str { - match self { - Self::QueueOnly => "send_message only supports text content in MultiAgentV2 for now", - Self::TriggerTurn => "assign_task only supports text content in MultiAgentV2 for now", - } - } - /// Returns whether the produced communication should start a turn immediately. fn apply(self, communication: InterAgentCommunication) -> InterAgentCommunication { match self { @@ -38,10 +29,19 @@ impl MessageDeliveryMode { } #[derive(Debug, Deserialize)] -/// Input shared by the MultiAgentV2 `send_message` and `assign_task` tools. -pub(crate) struct MessageToolArgs { +#[serde(deny_unknown_fields)] +/// Input for the MultiAgentV2 `send_message` tool. +pub(crate) struct SendMessageArgs { pub(crate) target: String, - pub(crate) items: Vec, + pub(crate) message: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +/// Input for the MultiAgentV2 `assign_task` tool. +pub(crate) struct AssignTaskArgs { + pub(crate) target: String, + pub(crate) message: String, #[serde(default)] pub(crate) interrupt: bool, } @@ -70,31 +70,39 @@ impl ToolOutput for MessageToolResult { } } -/// Validates that the tool input is non-empty text-only content and returns its preview string. -fn text_content( - items: &[UserInput], - mode: MessageDeliveryMode, -) -> Result { - if items.is_empty() { +fn message_content(message: String) -> Result { + if message.trim().is_empty() { return Err(FunctionCallError::RespondToModel( - "Items can't be empty".to_string(), + "Empty message can't be sent to an agent".to_string(), )); } - if items - .iter() - .all(|item| matches!(item, UserInput::Text { .. })) - { - return Ok(render_input_preview(&(items.to_vec().into()))); - } - Err(FunctionCallError::RespondToModel( - mode.unsupported_items_error().to_string(), - )) + Ok(message) } -/// Handles the shared MultiAgentV2 text-message flow for both `send_message` and `assign_task`. -pub(crate) async fn handle_message_tool( +/// Handles the shared MultiAgentV2 plain-text message flow for both `send_message` and `assign_task`. +pub(crate) async fn handle_message_string_tool( invocation: ToolInvocation, mode: MessageDeliveryMode, + target: String, + message: String, + interrupt: bool, +) -> Result { + handle_message_submission( + invocation, + mode, + target, + message_content(message)?, + interrupt, + ) + .await +} + +async fn handle_message_submission( + invocation: ToolInvocation, + mode: MessageDeliveryMode, + target: String, + prompt: String, + interrupt: bool, ) -> Result { let ToolInvocation { session, @@ -103,16 +111,24 @@ pub(crate) async fn handle_message_tool( call_id, .. } = invocation; - let arguments = function_arguments(payload)?; - let args: MessageToolArgs = parse_arguments(&arguments)?; - let receiver_thread_id = resolve_agent_target(&session, &turn, &args.target).await?; - let prompt = text_content(&args.items, mode)?; + let _ = payload; + let receiver_thread_id = resolve_agent_target(&session, &turn, &target).await?; let receiver_agent = session .services .agent_control .get_agent_metadata(receiver_thread_id) .unwrap_or_default(); - if args.interrupt { + if mode == MessageDeliveryMode::TriggerTurn + && receiver_agent + .agent_path + .as_ref() + .is_some_and(AgentPath::is_root) + { + return Err(FunctionCallError::RespondToModel( + "Tasks can't be assigned to the root agent".to_string(), + )); + } + if interrupt { session .services .agent_control diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs index ed10b124a6..a16aebd4c2 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs @@ -1,6 +1,7 @@ use super::message_tool::MessageDeliveryMode; use super::message_tool::MessageToolResult; -use super::message_tool::handle_message_tool; +use super::message_tool::SendMessageArgs; +use super::message_tool::handle_message_string_tool; use super::*; pub(crate) struct Handler; @@ -18,6 +19,15 @@ impl ToolHandler for Handler { } async fn handle(&self, invocation: ToolInvocation) -> Result { - handle_message_tool(invocation, MessageDeliveryMode::QueueOnly).await + let arguments = function_arguments(invocation.payload.clone())?; + let args: SendMessageArgs = parse_arguments(&arguments)?; + handle_message_string_tool( + invocation, + MessageDeliveryMode::QueueOnly, + args.target, + args.message, + /*interrupt*/ false, + ) + .await } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 360f5b5b8e..a49b4eb14b 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -1,4 +1,5 @@ use super::*; +use crate::agent::control::SpawnAgentForkMode; use crate::agent::control::SpawnAgentOptions; use crate::agent::control::render_input_preview; use crate::agent::next_thread_spawn_depth; @@ -33,13 +34,14 @@ impl ToolHandler for Handler { } = invocation; let arguments = function_arguments(payload)?; let args: SpawnAgentArgs = parse_arguments(&arguments)?; + let fork_mode = args.fork_mode()?; let role_name = args .agent_type .as_deref() .map(str::trim) .filter(|role| !role.is_empty()); - let initial_operation = parse_collab_input(args.message, args.items)?; + let initial_operation = parse_collab_input(Some(args.message), /*items*/ None)?; let prompt = render_input_preview(&initial_operation); let session_source = turn.session_source.clone(); @@ -113,7 +115,8 @@ impl ToolHandler for Handler { }, Some(spawn_source), SpawnAgentOptions { - fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), + fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()), + fork_mode, }, ) .await @@ -199,15 +202,54 @@ impl ToolHandler for Handler { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct SpawnAgentArgs { - message: Option, - items: Option>, + message: String, task_name: String, agent_type: Option, model: Option, reasoning_effort: Option, - #[serde(default)] - fork_context: bool, + fork_turns: Option, + fork_context: Option, +} + +impl SpawnAgentArgs { + fn fork_mode(&self) -> Result, FunctionCallError> { + if self.fork_context.is_some() { + return Err(FunctionCallError::RespondToModel( + "fork_context is not supported in MultiAgentV2; use fork_turns instead".to_string(), + )); + } + + let Some(fork_turns) = self + .fork_turns + .as_deref() + .map(str::trim) + .filter(|fork_turns| !fork_turns.is_empty()) + else { + return Ok(None); + }; + + if fork_turns.eq_ignore_ascii_case("none") { + return Ok(None); + } + if fork_turns.eq_ignore_ascii_case("all") { + return Ok(Some(SpawnAgentForkMode::FullHistory)); + } + + let last_n_turns = fork_turns.parse::().map_err(|_| { + FunctionCallError::RespondToModel( + "fork_turns must be `none`, `all`, or a positive integer string".to_string(), + ) + })?; + if last_n_turns == 0 { + return Err(FunctionCallError::RespondToModel( + "fork_turns must be `none`, `all`, or a positive integer string".to_string(), + )); + } + + Ok(Some(SpawnAgentForkMode::LastNTurns(last_n_turns))) + } } #[derive(Debug, Serialize)] diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs index 1b1dc90e42..bf3ad8e934 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs @@ -1,11 +1,6 @@ use super::*; -use crate::agent::status::is_final; -use futures::FutureExt; -use futures::StreamExt; -use futures::stream::FuturesUnordered; use std::collections::HashMap; use std::time::Duration; -use tokio::sync::watch::Receiver; use tokio::time::Instant; use tokio::time::timeout_at; @@ -33,21 +28,6 @@ impl ToolHandler for Handler { } = invocation; let arguments = function_arguments(payload)?; let args: WaitArgs = parse_arguments(&arguments)?; - let receiver_thread_ids = resolve_agent_targets(&session, &turn, args.targets).await?; - let mut receiver_agents = Vec::with_capacity(receiver_thread_ids.len()); - for receiver_thread_id in &receiver_thread_ids { - let agent_metadata = session - .services - .agent_control - .get_agent_metadata(*receiver_thread_id) - .unwrap_or_default(); - receiver_agents.push(CollabAgentRef { - thread_id: *receiver_thread_id, - agent_nickname: agent_metadata.agent_nickname, - agent_role: agent_metadata.agent_role, - }); - } - let timeout_ms = args.timeout_ms.unwrap_or(DEFAULT_WAIT_TIMEOUT_MS); let timeout_ms = match timeout_ms { ms if ms <= 0 => { @@ -58,91 +38,23 @@ impl ToolHandler for Handler { ms => ms.clamp(MIN_WAIT_TIMEOUT_MS, MAX_WAIT_TIMEOUT_MS), }; + let mut mailbox_seq_rx = session.subscribe_mailbox_seq(); + session .send_event( &turn, CollabWaitingBeginEvent { sender_thread_id: session.conversation_id, - receiver_thread_ids: receiver_thread_ids.clone(), - receiver_agents: receiver_agents.clone(), + receiver_thread_ids: Vec::new(), + receiver_agents: Vec::new(), call_id: call_id.clone(), } .into(), ) .await; - let mut status_rxs = Vec::with_capacity(receiver_thread_ids.len()); - let mut initial_final_statuses = Vec::new(); - for id in &receiver_thread_ids { - match session.services.agent_control.subscribe_status(*id).await { - Ok(rx) => { - let status = rx.borrow().clone(); - if is_final(&status) { - initial_final_statuses.push((*id, status)); - } - status_rxs.push((*id, rx)); - } - Err(crate::error::CodexErr::ThreadNotFound(_)) => { - initial_final_statuses.push((*id, AgentStatus::NotFound)); - } - Err(err) => { - let mut statuses = HashMap::with_capacity(1); - statuses.insert(*id, session.services.agent_control.get_status(*id).await); - session - .send_event( - &turn, - CollabWaitingEndEvent { - sender_thread_id: session.conversation_id, - call_id: call_id.clone(), - agent_statuses: build_wait_agent_statuses( - &statuses, - &receiver_agents, - ), - statuses, - } - .into(), - ) - .await; - return Err(collab_agent_error(*id, err)); - } - } - } - - let statuses = if !initial_final_statuses.is_empty() { - initial_final_statuses - } else { - let mut futures = FuturesUnordered::new(); - for (id, rx) in status_rxs { - let session = session.clone(); - futures.push(wait_for_final_status(session, id, rx)); - } - let mut results = Vec::new(); - let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64); - loop { - match timeout_at(deadline, futures.next()).await { - Ok(Some(Some(result))) => { - results.push(result); - break; - } - Ok(Some(None)) => continue, - Ok(None) | Err(_) => break, - } - } - if !results.is_empty() { - loop { - match futures.next().now_or_never() { - Some(Some(Some(result))) => results.push(result), - Some(Some(None)) => continue, - Some(None) | None => break, - } - } - } - results - }; - - let timed_out = statuses.is_empty(); - let statuses_by_id = statuses.clone().into_iter().collect::>(); - let agent_statuses = build_wait_agent_statuses(&statuses_by_id, &receiver_agents); + let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64); + let timed_out = !wait_for_mailbox_change(&mut mailbox_seq_rx, deadline).await; let result = WaitAgentResult::from_timed_out(timed_out); session @@ -151,8 +63,8 @@ impl ToolHandler for Handler { CollabWaitingEndEvent { sender_thread_id: session.conversation_id, call_id, - agent_statuses, - statuses: statuses_by_id, + agent_statuses: Vec::new(), + statuses: HashMap::new(), } .into(), ) @@ -163,9 +75,8 @@ impl ToolHandler for Handler { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct WaitArgs { - #[serde(default)] - targets: Vec, timeout_ms: Option, } @@ -207,24 +118,12 @@ impl ToolOutput for WaitAgentResult { } } -async fn wait_for_final_status( - session: std::sync::Arc, - thread_id: ThreadId, - mut status_rx: Receiver, -) -> Option<(ThreadId, AgentStatus)> { - let mut status = status_rx.borrow().clone(); - if is_final(&status) { - return Some((thread_id, status)); - } - - loop { - if status_rx.changed().await.is_err() { - let latest = session.services.agent_control.get_status(thread_id).await; - return is_final(&latest).then_some((thread_id, latest)); - } - status = status_rx.borrow().clone(); - if is_final(&status) { - return Some((thread_id, status)); - } +async fn wait_for_mailbox_change( + mailbox_seq_rx: &mut tokio::sync::watch::Receiver, + deadline: Instant, +) -> bool { + match timeout_at(deadline, mailbox_seq_rx.changed()).await { + Ok(Ok(())) => true, + Ok(Err(_)) | Err(_) => false, } } diff --git a/codex-rs/core/src/tools/handlers/plan.rs b/codex-rs/core/src/tools/handlers/plan.rs index af8dc2c310..ce0b98b1cb 100644 --- a/codex-rs/core/src/tools/handlers/plan.rs +++ b/codex-rs/core/src/tools/handlers/plan.rs @@ -1,5 +1,3 @@ -use crate::client_common::tools::ResponsesApiTool; -use crate::client_common::tools::ToolSpec; use crate::codex::Session; use crate::codex::TurnContext; use crate::function_tool::FunctionCallError; @@ -8,7 +6,6 @@ use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; -use crate::tools::spec::JsonSchema; use async_trait::async_trait; use codex_protocol::config_types::ModeKind; use codex_protocol::models::FunctionCallOutputPayload; @@ -16,8 +13,6 @@ use codex_protocol::models::ResponseInputItem; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::EventMsg; use serde_json::Value as JsonValue; -use std::collections::BTreeMap; -use std::sync::LazyLock; pub struct PlanHandler; @@ -49,50 +44,6 @@ impl ToolOutput for PlanToolOutput { } } -pub static PLAN_TOOL: LazyLock = LazyLock::new(|| { - let mut plan_item_props = BTreeMap::new(); - plan_item_props.insert("step".to_string(), JsonSchema::String { description: None }); - plan_item_props.insert( - "status".to_string(), - JsonSchema::String { - description: Some("One of: pending, in_progress, completed".to_string()), - }, - ); - - let plan_items_schema = JsonSchema::Array { - description: Some("The list of steps".to_string()), - items: Box::new(JsonSchema::Object { - properties: plan_item_props, - required: Some(vec!["step".to_string(), "status".to_string()]), - additional_properties: Some(false.into()), - }), - }; - - let mut properties = BTreeMap::new(); - properties.insert( - "explanation".to_string(), - JsonSchema::String { description: None }, - ); - properties.insert("plan".to_string(), plan_items_schema); - - ToolSpec::Function(ResponsesApiTool { - name: "update_plan".to_string(), - description: r#"Updates the task plan. -Provide an optional explanation and a list of plan items, each with a step and status. -At most one step can be in_progress at a time. -"# - .to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::Object { - properties, - required: Some(vec!["plan".to_string()]), - additional_properties: Some(false.into()), - }, - output_schema: None, - }) -}); - #[async_trait] impl ToolHandler for PlanHandler { type Output = PlanToolOutput; diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index 4df8328e70..e0755deac7 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -10,11 +10,6 @@ use crate::tools::handlers::parse_arguments_with_base_path; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; -pub(crate) fn request_permissions_tool_description() -> String { - "Request additional filesystem or network permissions from the user and wait for the client to grant a subset of the requested permission profile. Granted permissions apply automatically to later shell-like commands in the current turn, or for the rest of the session if the client approves them at session scope." - .to_string() -} - pub struct RequestPermissionsHandler; #[async_trait] diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs index 4d95a2c20d..f12defd0e7 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -6,50 +6,10 @@ use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; use async_trait::async_trait; -use codex_protocol::config_types::ModeKind; -use codex_protocol::config_types::TUI_VISIBLE_COLLABORATION_MODES; use codex_protocol::request_user_input::RequestUserInputArgs; - -fn request_user_input_is_available(mode: ModeKind, default_mode_request_user_input: bool) -> bool { - mode.allows_request_user_input() - || (default_mode_request_user_input && mode == ModeKind::Default) -} - -fn format_allowed_modes(default_mode_request_user_input: bool) -> String { - let mode_names: Vec<&str> = TUI_VISIBLE_COLLABORATION_MODES - .into_iter() - .filter(|mode| request_user_input_is_available(*mode, default_mode_request_user_input)) - .map(ModeKind::display_name) - .collect(); - - match mode_names.as_slice() { - [] => "no modes".to_string(), - [mode] => format!("{mode} mode"), - [first, second] => format!("{first} or {second} mode"), - [..] => format!("modes: {}", mode_names.join(",")), - } -} - -pub(crate) fn request_user_input_unavailable_message( - mode: ModeKind, - default_mode_request_user_input: bool, -) -> Option { - if request_user_input_is_available(mode, default_mode_request_user_input) { - None - } else { - let mode_name = mode.display_name(); - Some(format!( - "request_user_input is unavailable in {mode_name} mode" - )) - } -} - -pub(crate) fn request_user_input_tool_description(default_mode_request_user_input: bool) -> String { - let allowed_modes = format_allowed_modes(default_mode_request_user_input); - format!( - "Request user input for one to three short questions and wait for the response. This tool is only available in {allowed_modes}." - ) -} +use codex_tools::REQUEST_USER_INPUT_TOOL_NAME; +use codex_tools::normalize_request_user_input_args; +use codex_tools::request_user_input_unavailable_message; pub struct RequestUserInputHandler { pub default_mode_request_user_input: bool, @@ -75,9 +35,9 @@ impl ToolHandler for RequestUserInputHandler { let arguments = match payload { ToolPayload::Function { arguments } => arguments, _ => { - return Err(FunctionCallError::RespondToModel( - "request_user_input handler received unsupported payload".to_string(), - )); + return Err(FunctionCallError::RespondToModel(format!( + "{REQUEST_USER_INPUT_TOOL_NAME} handler received unsupported payload" + ))); } }; @@ -88,38 +48,24 @@ impl ToolHandler for RequestUserInputHandler { return Err(FunctionCallError::RespondToModel(message)); } - let mut args: RequestUserInputArgs = parse_arguments(&arguments)?; - let missing_options = args - .questions - .iter() - .any(|question| question.options.as_ref().is_none_or(Vec::is_empty)); - if missing_options { - return Err(FunctionCallError::RespondToModel( - "request_user_input requires non-empty options for every question".to_string(), - )); - } - for question in &mut args.questions { - question.is_other = true; - } + let args: RequestUserInputArgs = parse_arguments(&arguments)?; + let args = + normalize_request_user_input_args(args).map_err(FunctionCallError::RespondToModel)?; let response = session .request_user_input(turn.as_ref(), call_id, args) .await .ok_or_else(|| { - FunctionCallError::RespondToModel( - "request_user_input was cancelled before receiving a response".to_string(), - ) + FunctionCallError::RespondToModel(format!( + "{REQUEST_USER_INPUT_TOOL_NAME} was cancelled before receiving a response" + )) })?; let content = serde_json::to_string(&response).map_err(|err| { FunctionCallError::Fatal(format!( - "failed to serialize request_user_input response: {err}" + "failed to serialize {REQUEST_USER_INPUT_TOOL_NAME} response: {err}" )) })?; Ok(FunctionToolOutput::from_text(content, Some(true))) } } - -#[cfg(test)] -#[path = "request_user_input_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs deleted file mode 100644 index bf44233e72..0000000000 --- a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs +++ /dev/null @@ -1,61 +0,0 @@ -use super::*; -use pretty_assertions::assert_eq; - -#[test] -fn request_user_input_mode_availability_defaults_to_plan_only() { - assert!(ModeKind::Plan.allows_request_user_input()); - assert!(!ModeKind::Default.allows_request_user_input()); - assert!(!ModeKind::Execute.allows_request_user_input()); - assert!(!ModeKind::PairProgramming.allows_request_user_input()); -} - -#[test] -fn request_user_input_unavailable_messages_respect_default_mode_feature_flag() { - assert_eq!( - request_user_input_unavailable_message( - ModeKind::Plan, - /*default_mode_request_user_input*/ false - ), - None - ); - assert_eq!( - request_user_input_unavailable_message( - ModeKind::Default, - /*default_mode_request_user_input*/ false - ), - Some("request_user_input is unavailable in Default mode".to_string()) - ); - assert_eq!( - request_user_input_unavailable_message( - ModeKind::Default, - /*default_mode_request_user_input*/ true - ), - None - ); - assert_eq!( - request_user_input_unavailable_message( - ModeKind::Execute, - /*default_mode_request_user_input*/ false - ), - Some("request_user_input is unavailable in Execute mode".to_string()) - ); - assert_eq!( - request_user_input_unavailable_message( - ModeKind::PairProgramming, - /*default_mode_request_user_input*/ false - ), - Some("request_user_input is unavailable in Pair Programming mode".to_string()) - ); -} - -#[test] -fn request_user_input_tool_description_mentions_available_modes() { - assert_eq!( - request_user_input_tool_description(/*default_mode_request_user_input*/ false), - "Request user input for one to three short questions and wait for the response. This tool is only available in Plan mode.".to_string() - ); - assert_eq!( - request_user_input_tool_description(/*default_mode_request_user_input*/ true), - "Request user input for one to three short questions and wait for the response. This tool is only available in Default or Plan mode.".to_string() - ); -} diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 7f97f419d9..5191b893e2 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -37,9 +37,9 @@ use crate::tools::runtimes::shell::ShellRequest; use crate::tools::runtimes::shell::ShellRuntime; use crate::tools::runtimes::shell::ShellRuntimeBackend; use crate::tools::sandboxing::ToolCtx; -use crate::tools::spec::ShellCommandBackendConfig; use codex_features::Feature; use codex_protocol::models::PermissionProfile; +use codex_tools::ShellCommandBackendConfig; pub struct ShellHandler; diff --git a/codex-rs/core/src/tools/handlers/tool_search.rs b/codex-rs/core/src/tools/handlers/tool_search.rs index 487e57c355..dac79a257f 100644 --- a/codex-rs/core/src/tools/handlers/tool_search.rs +++ b/codex-rs/core/src/tools/handlers/tool_search.rs @@ -9,20 +9,16 @@ use async_trait::async_trait; use bm25::Document; use bm25::Language; use bm25::SearchEngineBuilder; -use codex_tools::ResponsesApiNamespace; -use codex_tools::ResponsesApiNamespaceTool; -use codex_tools::ToolSearchOutputTool; -use codex_tools::mcp_tool_to_deferred_responses_api_tool; -use std::collections::BTreeMap; +use codex_tools::TOOL_SEARCH_DEFAULT_LIMIT; +use codex_tools::TOOL_SEARCH_TOOL_NAME; +use codex_tools::ToolSearchResultSource; +use codex_tools::collect_tool_search_output_tools; use std::collections::HashMap; pub struct ToolSearchHandler { tools: HashMap, } -pub(crate) const TOOL_SEARCH_TOOL_NAME: &str = "tool_search"; -pub(crate) const DEFAULT_LIMIT: usize = 8; - impl ToolSearchHandler { pub fn new(tools: HashMap) -> Self { Self { tools } @@ -58,7 +54,7 @@ impl ToolHandler for ToolSearchHandler { "query must not be empty".to_string(), )); } - let limit = args.limit.unwrap_or(DEFAULT_LIMIT); + let limit = args.limit.unwrap_or(TOOL_SEARCH_DEFAULT_LIMIT); if limit == 0 { return Err(FunctionCallError::RespondToModel( @@ -82,65 +78,28 @@ impl ToolHandler for ToolSearchHandler { SearchEngineBuilder::::with_documents(Language::English, documents).build(); let results = search_engine.search(query, limit); - let matched_entries = results - .into_iter() - .filter_map(|result| entries.get(result.document.id)) - .collect::>(); - let tools = serialize_tool_search_output_tools(&matched_entries).map_err(|err| { - FunctionCallError::Fatal(format!("failed to encode tool_search output: {err}")) + let tools = collect_tool_search_output_tools( + results + .into_iter() + .filter_map(|result| entries.get(result.document.id)) + .map(|(_name, tool)| ToolSearchResultSource { + tool_namespace: tool.tool_namespace.as_str(), + tool_name: tool.tool_name.as_str(), + tool: &tool.tool, + connector_name: tool.connector_name.as_deref(), + connector_description: tool.connector_description.as_deref(), + }), + ) + .map_err(|err| { + FunctionCallError::Fatal(format!( + "failed to encode {TOOL_SEARCH_TOOL_NAME} output: {err}" + )) })?; Ok(ToolSearchOutput { tools }) } } -fn serialize_tool_search_output_tools( - matched_entries: &[&(String, ToolInfo)], -) -> Result, serde_json::Error> { - let grouped: BTreeMap> = - matched_entries - .iter() - .fold(BTreeMap::new(), |mut acc, (_name, tool)| { - acc.entry(tool.tool_namespace.clone()) - .or_default() - .push(tool.clone()); - - acc - }); - - let mut results = Vec::with_capacity(grouped.len()); - for (namespace, tools) in grouped { - let Some(first_tool) = tools.first() else { - continue; - }; - - let description = first_tool.connector_description.clone().or_else(|| { - first_tool - .connector_name - .as_deref() - .map(str::trim) - .filter(|connector_name| !connector_name.is_empty()) - .map(|connector_name| format!("Tools for working with {connector_name}.")) - }); - - let tools = tools - .iter() - .map(|tool| { - mcp_tool_to_deferred_responses_api_tool(tool.tool_name.clone(), &tool.tool) - .map(ResponsesApiNamespaceTool::Function) - }) - .collect::, _>>()?; - - results.push(ToolSearchOutputTool::Namespace(ResponsesApiNamespace { - name: namespace, - description: description.unwrap_or_default(), - tools, - })); - } - - Ok(results) -} - fn build_search_text(name: &str, info: &ToolInfo) -> String { let mut parts = vec![ name.to_string(), @@ -183,7 +142,3 @@ fn build_search_text(name: &str, info: &ToolInfo) -> String { parts.join(" ") } - -#[cfg(test)] -#[path = "tool_search_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/tools/handlers/tool_search_tests.rs b/codex-rs/core/src/tools/handlers/tool_search_tests.rs deleted file mode 100644 index 9a90ad738c..0000000000 --- a/codex-rs/core/src/tools/handlers/tool_search_tests.rs +++ /dev/null @@ -1,199 +0,0 @@ -use super::*; -use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; -use codex_tools::ResponsesApiTool; -use pretty_assertions::assert_eq; -use rmcp::model::JsonObject; -use rmcp::model::Tool; -use serde_json::json; -use std::sync::Arc; - -#[test] -fn serialize_tool_search_output_tools_groups_results_by_namespace() { - let entries = [ - ( - "mcp__codex_apps__calendar_create_event".to_string(), - ToolInfo { - server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), - tool_name: "_create_event".to_string(), - tool_namespace: "mcp__codex_apps__calendar".to_string(), - tool: Tool { - name: "calendar-create-event".to_string().into(), - title: None, - description: Some("Create a calendar event.".into()), - input_schema: Arc::new(JsonObject::from_iter([( - "type".to_string(), - json!("object"), - )])), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, - connector_id: Some("calendar".to_string()), - connector_name: Some("Calendar".to_string()), - plugin_display_names: Vec::new(), - connector_description: Some("Plan events".to_string()), - }, - ), - ( - "mcp__codex_apps__gmail_read_email".to_string(), - ToolInfo { - server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), - tool_name: "_read_email".to_string(), - tool_namespace: "mcp__codex_apps__gmail".to_string(), - tool: Tool { - name: "gmail-read-email".to_string().into(), - title: None, - description: Some("Read an email.".into()), - input_schema: Arc::new(JsonObject::from_iter([( - "type".to_string(), - json!("object"), - )])), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, - connector_id: Some("gmail".to_string()), - connector_name: Some("Gmail".to_string()), - plugin_display_names: Vec::new(), - connector_description: Some("Read mail".to_string()), - }, - ), - ( - "mcp__codex_apps__calendar_list_events".to_string(), - ToolInfo { - server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), - tool_name: "_list_events".to_string(), - tool_namespace: "mcp__codex_apps__calendar".to_string(), - tool: Tool { - name: "calendar-list-events".to_string().into(), - title: None, - description: Some("List calendar events.".into()), - input_schema: Arc::new(JsonObject::from_iter([( - "type".to_string(), - json!("object"), - )])), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, - connector_id: Some("calendar".to_string()), - connector_name: Some("Calendar".to_string()), - plugin_display_names: Vec::new(), - connector_description: Some("Plan events".to_string()), - }, - ), - ]; - - let tools = serialize_tool_search_output_tools(&[&entries[0], &entries[1], &entries[2]]) - .expect("serialize tool search output"); - - assert_eq!( - tools, - vec![ - ToolSearchOutputTool::Namespace(ResponsesApiNamespace { - name: "mcp__codex_apps__calendar".to_string(), - description: "Plan events".to_string(), - tools: vec![ - ResponsesApiNamespaceTool::Function(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, - }), - ResponsesApiNamespaceTool::Function(ResponsesApiTool { - name: "_list_events".to_string(), - description: "List calendar events.".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, - }), - ], - }), - ToolSearchOutputTool::Namespace(ResponsesApiNamespace { - name: "mcp__codex_apps__gmail".to_string(), - description: "Read mail".to_string(), - tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { - name: "_read_email".to_string(), - description: "Read an email.".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, - })], - }) - ] - ); -} - -#[test] -fn serialize_tool_search_output_tools_falls_back_to_connector_name_description() { - let entries = [( - "mcp__codex_apps__gmail_batch_read_email".to_string(), - ToolInfo { - server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), - tool_name: "_batch_read_email".to_string(), - tool_namespace: "mcp__codex_apps__gmail".to_string(), - tool: Tool { - name: "gmail-batch-read-email".to_string().into(), - title: None, - description: Some("Read multiple emails.".into()), - input_schema: Arc::new(JsonObject::from_iter([( - "type".to_string(), - json!("object"), - )])), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, - connector_id: Some("connector_gmail_456".to_string()), - connector_name: Some("Gmail".to_string()), - plugin_display_names: Vec::new(), - connector_description: None, - }, - )]; - - let tools = serialize_tool_search_output_tools(&[&entries[0]]).expect("serialize"); - - assert_eq!( - tools, - vec![ToolSearchOutputTool::Namespace(ResponsesApiNamespace { - name: "mcp__codex_apps__gmail".to_string(), - description: "Tools for working with Gmail.".to_string(), - tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { - name: "_batch_read_email".to_string(), - description: "Read multiple emails.".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, - })], - })] - ); -} diff --git a/codex-rs/core/src/tools/handlers/tool_suggest.rs b/codex-rs/core/src/tools/handlers/tool_suggest.rs index bdffbbbada..45b9fada27 100644 --- a/codex-rs/core/src/tools/handlers/tool_suggest.rs +++ b/codex-rs/core/src/tools/handlers/tool_suggest.rs @@ -1,17 +1,19 @@ -use std::collections::BTreeMap; use std::collections::HashSet; use async_trait::async_trait; use codex_app_server_protocol::AppInfo; -use codex_app_server_protocol::McpElicitationObjectType; -use codex_app_server_protocol::McpElicitationSchema; -use codex_app_server_protocol::McpServerElicitationRequest; -use codex_app_server_protocol::McpServerElicitationRequestParams; use codex_rmcp_client::ElicitationAction; +use codex_tools::DiscoverableTool; +use codex_tools::DiscoverableToolAction; +use codex_tools::DiscoverableToolType; +use codex_tools::TOOL_SUGGEST_TOOL_NAME; +use codex_tools::ToolSuggestArgs; +use codex_tools::ToolSuggestResult; +use codex_tools::all_suggested_connectors_picked_up; +use codex_tools::build_tool_suggestion_elicitation_request; +use codex_tools::filter_tool_suggest_discoverable_tools_for_client; +use codex_tools::verified_connector_suggestion_completed; use rmcp::model::RequestId; -use serde::Deserialize; -use serde::Serialize; -use serde_json::json; use tracing::warn; use crate::connectors; @@ -20,50 +22,12 @@ use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; -use crate::tools::discoverable::DiscoverableTool; -use crate::tools::discoverable::DiscoverableToolAction; -use crate::tools::discoverable::DiscoverableToolType; -use crate::tools::discoverable::filter_tool_suggest_discoverable_tools_for_client; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; pub struct ToolSuggestHandler; -pub(crate) const TOOL_SUGGEST_TOOL_NAME: &str = "tool_suggest"; -const TOOL_SUGGEST_APPROVAL_KIND_VALUE: &str = "tool_suggestion"; - -#[derive(Debug, Deserialize)] -struct ToolSuggestArgs { - tool_type: DiscoverableToolType, - action_type: DiscoverableToolAction, - tool_id: String, - suggest_reason: String, -} - -#[derive(Debug, Serialize, PartialEq, Eq)] -struct ToolSuggestResult { - completed: bool, - user_confirmed: bool, - tool_type: DiscoverableToolType, - action_type: DiscoverableToolAction, - tool_id: String, - tool_name: String, - suggest_reason: String, -} - -#[derive(Debug, Serialize, PartialEq, Eq)] -struct ToolSuggestMeta<'a> { - codex_approval_kind: &'static str, - tool_type: DiscoverableToolType, - suggest_type: DiscoverableToolAction, - suggest_reason: &'a str, - tool_id: &'a str, - tool_name: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - install_url: Option<&'a str>, -} - #[async_trait] impl ToolHandler for ToolSuggestHandler { type Output = FunctionToolOutput; @@ -147,6 +111,7 @@ impl ToolHandler for ToolSuggestHandler { let request_id = RequestId::String(format!("tool_suggestion_{call_id}").into()); let params = build_tool_suggestion_elicitation_request( + CODEX_APPS_MCP_SERVER_NAME, session.conversation_id.to_string(), turn.sub_id.clone(), &args, @@ -191,60 +156,6 @@ impl ToolHandler for ToolSuggestHandler { } } -fn build_tool_suggestion_elicitation_request( - thread_id: String, - turn_id: String, - args: &ToolSuggestArgs, - suggest_reason: &str, - tool: &DiscoverableTool, -) -> McpServerElicitationRequestParams { - let tool_name = tool.name().to_string(); - let install_url = tool.install_url().map(ToString::to_string); - let message = suggest_reason.to_string(); - - McpServerElicitationRequestParams { - thread_id, - turn_id: Some(turn_id), - server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), - request: McpServerElicitationRequest::Form { - meta: Some(json!(build_tool_suggestion_meta( - args.tool_type, - args.action_type, - suggest_reason, - tool.id(), - tool_name.as_str(), - install_url.as_deref(), - ))), - message, - requested_schema: McpElicitationSchema { - schema_uri: None, - type_: McpElicitationObjectType::Object, - properties: BTreeMap::new(), - required: None, - }, - }, - } -} - -fn build_tool_suggestion_meta<'a>( - tool_type: DiscoverableToolType, - action_type: DiscoverableToolAction, - suggest_reason: &'a str, - tool_id: &'a str, - tool_name: &'a str, - install_url: Option<&'a str>, -) -> ToolSuggestMeta<'a> { - ToolSuggestMeta { - codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE, - tool_type, - suggest_type: action_type, - suggest_reason, - tool_id, - tool_name, - install_url, - } -} - async fn verify_tool_suggestion_completed( session: &crate::codex::Session, turn: &crate::codex::TurnContext, @@ -327,25 +238,6 @@ async fn refresh_missing_suggested_connectors( } } -fn all_suggested_connectors_picked_up( - expected_connector_ids: &[String], - accessible_connectors: &[AppInfo], -) -> bool { - expected_connector_ids.iter().all(|connector_id| { - verified_connector_suggestion_completed(connector_id, accessible_connectors) - }) -} - -fn verified_connector_suggestion_completed( - tool_id: &str, - accessible_connectors: &[AppInfo], -) -> bool { - accessible_connectors - .iter() - .find(|connector| connector.id == tool_id) - .is_some_and(|connector| connector.is_accessible) -} - fn verified_plugin_suggestion_completed( tool_id: &str, config: &crate::config::Config, diff --git a/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs b/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs index 32d5061aed..77ab793dc0 100644 --- a/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs +++ b/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs @@ -5,264 +5,9 @@ use crate::plugins::test_support::load_plugins_config; use crate::plugins::test_support::write_curated_plugin_sha; use crate::plugins::test_support::write_openai_curated_marketplace; use crate::plugins::test_support::write_plugins_feature_config; -use crate::tools::discoverable::DiscoverablePluginInfo; -use crate::tools::discoverable::filter_tool_suggest_discoverable_tools_for_client; -use codex_app_server_protocol::AppInfo; use codex_utils_absolute_path::AbsolutePathBuf; -use pretty_assertions::assert_eq; -use serde_json::json; -use std::collections::BTreeMap; use tempfile::tempdir; -#[test] -fn build_tool_suggestion_elicitation_request_uses_expected_shape() { - let args = ToolSuggestArgs { - tool_type: DiscoverableToolType::Connector, - action_type: DiscoverableToolAction::Install, - tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(), - suggest_reason: "Plan and reference events from your calendar".to_string(), - }; - let connector = DiscoverableTool::Connector(Box::new(AppInfo { - id: "connector_2128aebfecb84f64a069897515042a44".to_string(), - name: "Google Calendar".to_string(), - description: Some("Plan events and schedules.".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/google-calendar/connector_2128aebfecb84f64a069897515042a44" - .to_string(), - ), - is_accessible: false, - is_enabled: true, - plugin_display_names: Vec::new(), - })); - - let request = build_tool_suggestion_elicitation_request( - "thread-1".to_string(), - "turn-1".to_string(), - &args, - "Plan and reference events from your calendar", - &connector, - ); - - assert_eq!( - request, - McpServerElicitationRequestParams { - thread_id: "thread-1".to_string(), - turn_id: Some("turn-1".to_string()), - server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), - request: McpServerElicitationRequest::Form { - meta: Some(json!(ToolSuggestMeta { - codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE, - tool_type: DiscoverableToolType::Connector, - suggest_type: DiscoverableToolAction::Install, - suggest_reason: "Plan and reference events from your calendar", - tool_id: "connector_2128aebfecb84f64a069897515042a44", - tool_name: "Google Calendar", - install_url: Some( - "https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44" - ), - })), - message: "Plan and reference events from your calendar".to_string(), - requested_schema: McpElicitationSchema { - schema_uri: None, - type_: McpElicitationObjectType::Object, - properties: BTreeMap::new(), - required: None, - }, - }, - } - ); -} - -#[test] -fn build_tool_suggestion_elicitation_request_for_plugin_omits_install_url() { - let args = ToolSuggestArgs { - tool_type: DiscoverableToolType::Plugin, - action_type: DiscoverableToolAction::Install, - tool_id: "sample@openai-curated".to_string(), - suggest_reason: "Use the sample plugin's skills and MCP server".to_string(), - }; - let plugin = DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo { - id: "sample@openai-curated".to_string(), - name: "Sample Plugin".to_string(), - description: Some("Includes skills, MCP servers, and apps.".to_string()), - has_skills: true, - mcp_server_names: vec!["sample-docs".to_string()], - app_connector_ids: vec!["connector_calendar".to_string()], - })); - - let request = build_tool_suggestion_elicitation_request( - "thread-1".to_string(), - "turn-1".to_string(), - &args, - "Use the sample plugin's skills and MCP server", - &plugin, - ); - - assert_eq!( - request, - McpServerElicitationRequestParams { - thread_id: "thread-1".to_string(), - turn_id: Some("turn-1".to_string()), - server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), - request: McpServerElicitationRequest::Form { - meta: Some(json!(ToolSuggestMeta { - codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE, - tool_type: DiscoverableToolType::Plugin, - suggest_type: DiscoverableToolAction::Install, - suggest_reason: "Use the sample plugin's skills and MCP server", - tool_id: "sample@openai-curated", - tool_name: "Sample Plugin", - install_url: None, - })), - message: "Use the sample plugin's skills and MCP server".to_string(), - requested_schema: McpElicitationSchema { - schema_uri: None, - type_: McpElicitationObjectType::Object, - properties: BTreeMap::new(), - required: None, - }, - }, - } - ); -} - -#[test] -fn build_tool_suggestion_meta_uses_expected_shape() { - let meta = build_tool_suggestion_meta( - DiscoverableToolType::Connector, - DiscoverableToolAction::Install, - "Find and reference emails from your inbox", - "connector_68df038e0ba48191908c8434991bbac2", - "Gmail", - Some("https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2"), - ); - - assert_eq!( - meta, - ToolSuggestMeta { - codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE, - tool_type: DiscoverableToolType::Connector, - suggest_type: DiscoverableToolAction::Install, - suggest_reason: "Find and reference emails from your inbox", - tool_id: "connector_68df038e0ba48191908c8434991bbac2", - tool_name: "Gmail", - install_url: Some( - "https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2" - ), - } - ); -} - -#[test] -fn filter_tool_suggest_discoverable_tools_for_codex_tui_omits_plugins() { - let discoverable_tools = vec![ - DiscoverableTool::Connector(Box::new(AppInfo { - id: "connector_google_calendar".to_string(), - name: "Google Calendar".to_string(), - description: Some("Plan events and schedules.".to_string()), - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some("https://example.test/google-calendar".to_string()), - is_accessible: false, - is_enabled: true, - plugin_display_names: Vec::new(), - })), - DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo { - id: "slack@openai-curated".to_string(), - name: "Slack".to_string(), - description: Some("Search Slack messages".to_string()), - has_skills: true, - mcp_server_names: vec!["slack".to_string()], - app_connector_ids: vec!["connector_slack".to_string()], - })), - ]; - - assert_eq!( - filter_tool_suggest_discoverable_tools_for_client(discoverable_tools, Some("codex-tui"),), - vec![DiscoverableTool::Connector(Box::new(AppInfo { - id: "connector_google_calendar".to_string(), - name: "Google Calendar".to_string(), - description: Some("Plan events and schedules.".to_string()), - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some("https://example.test/google-calendar".to_string()), - is_accessible: false, - is_enabled: true, - plugin_display_names: Vec::new(), - }))] - ); -} - -#[test] -fn verified_connector_suggestion_completed_requires_accessible_connector() { - let accessible_connectors = vec![AppInfo { - id: "calendar".to_string(), - name: "Google Calendar".to_string(), - description: None, - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: None, - is_accessible: true, - is_enabled: false, - plugin_display_names: Vec::new(), - }]; - - assert!(verified_connector_suggestion_completed( - "calendar", - &accessible_connectors, - )); - assert!(!verified_connector_suggestion_completed( - "gmail", - &accessible_connectors, - )); -} - -#[test] -fn all_suggested_connectors_picked_up_requires_every_expected_connector() { - let accessible_connectors = vec![AppInfo { - id: "calendar".to_string(), - name: "Google Calendar".to_string(), - description: None, - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: None, - is_accessible: true, - is_enabled: false, - plugin_display_names: Vec::new(), - }]; - - assert!(all_suggested_connectors_picked_up( - &["calendar".to_string()], - &accessible_connectors, - )); - assert!(!all_suggested_connectors_picked_up( - &["calendar".to_string(), "gmail".to_string()], - &accessible_connectors, - )); -} - #[tokio::test] async fn verified_plugin_suggestion_completed_requires_installed_plugin() { let codex_home = tempdir().expect("tempdir should succeed"); diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index b512a878b6..1b0e393c6b 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -21,7 +21,6 @@ use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::PreToolUsePayload; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; -use crate::tools::spec::UnifiedExecShellMode; use crate::unified_exec::ExecCommandRequest; use crate::unified_exec::UnifiedExecContext; use crate::unified_exec::UnifiedExecProcessManager; @@ -31,6 +30,7 @@ use codex_features::Feature; use codex_otel::SessionTelemetry; use codex_otel::metrics::names::TOOL_CALL_UNIFIED_EXEC_METRIC; use codex_protocol::models::PermissionProfile; +use codex_tools::UnifiedExecShellMode; use serde::Deserialize; use std::path::PathBuf; use std::sync::Arc; diff --git a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs index 11b75b28d4..7c40eeff1d 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs @@ -2,9 +2,10 @@ use super::*; use crate::shell::default_user_shell; use crate::tools::handlers::parse_arguments_with_base_path; use crate::tools::handlers::resolve_workdir_base_path; -use crate::tools::spec::ZshForkConfig; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::PermissionProfile; +use codex_tools::UnifiedExecShellMode; +use codex_tools::ZshForkConfig; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::fs; diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index beb2930b22..0cfbd2a60c 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -31,7 +31,6 @@ use tracing::trace; use tracing::warn; use uuid::Uuid; -use crate::client_common::tools::ToolSpec; use crate::codex::Session; use crate::codex::TurnContext; use crate::exec::ExecCapturePolicy; @@ -46,6 +45,7 @@ use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxablePreference; +use codex_tools::ToolSpec; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::truncate_text; diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 57859bba42..e866d1f5f7 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -1,6 +1,5 @@ pub mod code_mode; pub mod context; -pub(crate) mod discoverable; pub mod events; pub(crate) mod handlers; pub mod js_repl; diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index 0cc0989fb9..dbcf4f6391 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -9,7 +9,6 @@ use tracing::Instrument; use tracing::instrument; use tracing::trace_span; -use crate::client_common::tools::ToolSpec; use crate::codex::Session; use crate::codex::TurnContext; use crate::error::CodexErr; @@ -22,6 +21,7 @@ use crate::tools::router::ToolCall; use crate::tools::router::ToolCallSource; use crate::tools::router::ToolRouter; use codex_protocol::models::ResponseInputItem; +use codex_tools::ToolSpec; #[derive(Clone)] pub(crate) struct ToolCallRuntime { diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 0e77caac72..6cacfd27ac 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -3,7 +3,6 @@ use std::sync::Arc; use std::time::Duration; use std::time::Instant; -use crate::client_common::tools::ToolSpec; use crate::function_tool::FunctionCallError; use crate::hook_runtime::record_additional_contexts; use crate::hook_runtime::run_post_tool_use_hooks; @@ -25,6 +24,7 @@ use codex_hooks::HookToolInputLocalShell; use codex_hooks::HookToolKind; use codex_protocol::models::ResponseInputItem; use codex_tools::ConfiguredToolSpec; +use codex_tools::ToolSpec; use codex_utils_readiness::Readiness; use serde_json::Value; use tracing::warn; diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 1145e3a8f2..1fe1005138 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -1,4 +1,3 @@ -use crate::client_common::tools::ToolSpec; use crate::codex::Session; use crate::codex::TurnContext; use crate::function_tool::FunctionCallError; @@ -7,10 +6,8 @@ use crate::sandboxing::SandboxPermissions; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; -use crate::tools::discoverable::DiscoverableTool; use crate::tools::registry::AnyToolResult; use crate::tools::registry::ToolRegistry; -use crate::tools::spec::ToolsConfig; use crate::tools::spec::build_specs_with_discoverable_tools; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::LocalShellAction; @@ -18,6 +15,9 @@ use codex_protocol::models::ResponseItem; use codex_protocol::models::SearchToolCallParams; use codex_protocol::models::ShellToolCallParams; use codex_tools::ConfiguredToolSpec; +use codex_tools::DiscoverableTool; +use codex_tools::ToolSpec; +use codex_tools::ToolsConfig; use rmcp::model::Tool; use std::collections::HashMap; use std::sync::Arc; diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 65a3981cca..20f5cc591e 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -60,7 +60,6 @@ impl ApplyPatchRuntime { id: call_id.to_string(), cwd: req.action.cwd.clone(), files: req.file_paths.clone(), - change_count: req.changes.len(), patch: req.action.patch.clone(), } } diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index a94f1f99b8..3d5ac27cb2 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -63,7 +63,6 @@ fn guardian_review_request_includes_patch_context() { id: "call-1".to_string(), cwd: expected_cwd, files: request.file_paths, - change_count: 1usize, patch: expected_patch, } ); diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 6ceef4c2c7..3c13ab4424 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -27,6 +27,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::GuardianCommandSource; use codex_protocol::protocol::NetworkPolicyRuleAction; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; @@ -187,7 +188,7 @@ pub(super) async fn try_run_zsh_fork( session: Arc::clone(&ctx.session), turn: Arc::clone(&ctx.turn), call_id: ctx.call_id.clone(), - tool_name: "shell", + tool_name: GuardianCommandSource::Shell, approval_policy: ctx.turn.approval_policy.value(), sandbox_policy: command_executor.sandbox_policy.clone(), file_system_sandbox_policy: command_executor.file_system_sandbox_policy.clone(), @@ -259,7 +260,7 @@ pub(crate) async fn prepare_unified_exec_zsh_fork( session: Arc::clone(&ctx.session), turn: Arc::clone(&ctx.turn), call_id: ctx.call_id.clone(), - tool_name: "exec_command", + tool_name: GuardianCommandSource::UnifiedExec, approval_policy: ctx.turn.approval_policy.value(), sandbox_policy: exec_request.sandbox_policy.clone(), file_system_sandbox_policy: exec_request.file_system_sandbox_policy.clone(), @@ -294,7 +295,7 @@ struct CoreShellActionProvider { session: Arc, turn: Arc, call_id: String, - tool_name: &'static str, + tool_name: GuardianCommandSource, approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, file_system_sandbox_policy: FileSystemSandboxPolicy, @@ -380,7 +381,7 @@ impl CoreShellActionProvider { let turn = self.turn.clone(); let call_id = self.call_id.clone(); let approval_id = Some(Uuid::new_v4().to_string()); - let tool_name = self.tool_name; + let source = self.tool_name; Ok(stopwatch .pause_for(async move { if routes_approval_to_guardian(&turn) { @@ -389,7 +390,7 @@ impl CoreShellActionProvider { &turn, GuardianApprovalRequest::Execve { id: call_id.clone(), - tool_name: tool_name.to_string(), + source, program: program.to_string_lossy().into_owned(), argv: argv.to_vec(), cwd: workdir, diff --git a/codex-rs/core/src/tools/runtimes/shell/zsh_fork_backend.rs b/codex-rs/core/src/tools/runtimes/shell/zsh_fork_backend.rs index 0ac9e08e0a..527be2d8a4 100644 --- a/codex-rs/core/src/tools/runtimes/shell/zsh_fork_backend.rs +++ b/codex-rs/core/src/tools/runtimes/shell/zsh_fork_backend.rs @@ -5,8 +5,8 @@ use crate::tools::runtimes::unified_exec::UnifiedExecRequest; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; -use crate::tools::spec::ZshForkConfig; use crate::unified_exec::SpawnLifecycleHandle; +use codex_tools::ZshForkConfig; pub(crate) struct PreparedUnifiedExecSpawn { pub(crate) exec_request: ExecRequest, diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 9211fb08a7..4f5e970eda 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -32,7 +32,6 @@ use crate::tools::sandboxing::ToolError; use crate::tools::sandboxing::ToolRuntime; use crate::tools::sandboxing::sandbox_override_for_first_attempt; use crate::tools::sandboxing::with_cached_approval; -use crate::tools::spec::UnifiedExecShellMode; use crate::unified_exec::NoopSpawnLifecycle; use crate::unified_exec::UnifiedExecError; use crate::unified_exec::UnifiedExecProcess; @@ -41,6 +40,7 @@ use codex_network_proxy::NetworkProxy; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::ReviewDecision; use codex_sandboxing::SandboxablePreference; +use codex_tools::UnifiedExecShellMode; use futures::future::BoxFuture; use std::collections::HashMap; use std::path::PathBuf; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 2e0dcf0845..74f46c2a93 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1,1853 +1,101 @@ -use crate::client_common::tools::ToolSpec; -use crate::config::AgentRoleConfig; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp_connection_manager::ToolInfo; -use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig; -use crate::original_image_detail::can_request_original_image_detail; use crate::shell::Shell; use crate::shell::ShellType; use crate::tools::code_mode::PUBLIC_TOOL_NAME; use crate::tools::code_mode::WAIT_TOOL_NAME; -use crate::tools::discoverable::DiscoverablePluginInfo; -use crate::tools::discoverable::DiscoverableTool; -use crate::tools::discoverable::DiscoverableToolAction; -use crate::tools::discoverable::DiscoverableToolType; -use crate::tools::handlers::PLAN_TOOL; -use crate::tools::handlers::TOOL_SEARCH_DEFAULT_LIMIT; -use crate::tools::handlers::TOOL_SEARCH_TOOL_NAME; -use crate::tools::handlers::TOOL_SUGGEST_TOOL_NAME; use crate::tools::handlers::agent_jobs::BatchJobHandler; -use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool; -use crate::tools::handlers::apply_patch::create_apply_patch_json_tool; use crate::tools::handlers::multi_agents_common::DEFAULT_WAIT_TIMEOUT_MS; use crate::tools::handlers::multi_agents_common::MAX_WAIT_TIMEOUT_MS; use crate::tools::handlers::multi_agents_common::MIN_WAIT_TIMEOUT_MS; -use crate::tools::handlers::request_permissions_tool_description; -use crate::tools::handlers::request_user_input_tool_description; use crate::tools::registry::ToolRegistryBuilder; use crate::tools::registry::tool_handler_key; -use codex_features::Feature; -use codex_features::Features; -use codex_protocol::config_types::WebSearchConfig; -use codex_protocol::config_types::WebSearchMode; -use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::openai_models::ApplyPatchToolType; use codex_protocol::openai_models::ConfigShellToolType; -use codex_protocol::openai_models::InputModality; -use codex_protocol::openai_models::ModelInfo; -use codex_protocol::openai_models::ModelPreset; -use codex_protocol::openai_models::WebSearchToolType; -use codex_protocol::protocol::SandboxPolicy; -use codex_protocol::protocol::SessionSource; -use codex_protocol::protocol::SubAgentSource; use codex_tools::CommandToolOptions; -use codex_tools::FreeformTool; -use codex_tools::FreeformToolFormat; -use codex_tools::ResponsesApiTool; +use codex_tools::DiscoverableTool; +use codex_tools::REQUEST_USER_INPUT_TOOL_NAME; use codex_tools::ShellToolOptions; +use codex_tools::SpawnAgentToolOptions; +use codex_tools::TOOL_SEARCH_DEFAULT_LIMIT; +use codex_tools::TOOL_SEARCH_TOOL_NAME; +use codex_tools::TOOL_SUGGEST_TOOL_NAME; +use codex_tools::ToolSearchAppSource; +use codex_tools::ToolSpec; +use codex_tools::ToolUserShellType; +use codex_tools::ToolsConfig; use codex_tools::ViewImageToolOptions; +use codex_tools::WaitAgentTimeoutOptions; +use codex_tools::WebSearchToolOptions; use codex_tools::augment_tool_spec_for_code_mode; +use codex_tools::collect_tool_search_app_infos; +use codex_tools::collect_tool_suggest_entries; +use codex_tools::create_apply_patch_freeform_tool; +use codex_tools::create_apply_patch_json_tool; +use codex_tools::create_assign_task_tool; +use codex_tools::create_close_agent_tool_v1; +use codex_tools::create_close_agent_tool_v2; +use codex_tools::create_code_mode_tool; use codex_tools::create_exec_command_tool; +use codex_tools::create_image_generation_tool; +use codex_tools::create_js_repl_reset_tool; +use codex_tools::create_js_repl_tool; +use codex_tools::create_list_agents_tool; +use codex_tools::create_list_dir_tool; +use codex_tools::create_list_mcp_resource_templates_tool; +use codex_tools::create_list_mcp_resources_tool; +use codex_tools::create_local_shell_tool; +use codex_tools::create_read_mcp_resource_tool; +use codex_tools::create_report_agent_job_result_tool; use codex_tools::create_request_permissions_tool; +use codex_tools::create_request_user_input_tool; +use codex_tools::create_resume_agent_tool; +use codex_tools::create_send_input_tool_v1; +use codex_tools::create_send_message_tool; use codex_tools::create_shell_command_tool; use codex_tools::create_shell_tool; +use codex_tools::create_spawn_agent_tool_v1; +use codex_tools::create_spawn_agent_tool_v2; +use codex_tools::create_spawn_agents_on_csv_tool; +use codex_tools::create_test_sync_tool; +use codex_tools::create_tool_search_tool; +use codex_tools::create_tool_suggest_tool; +use codex_tools::create_update_plan_tool; use codex_tools::create_view_image_tool; +use codex_tools::create_wait_agent_tool_v1; +use codex_tools::create_wait_agent_tool_v2; +use codex_tools::create_wait_tool; +use codex_tools::create_web_search_tool; use codex_tools::create_write_stdin_tool; use codex_tools::dynamic_tool_to_responses_api_tool; use codex_tools::mcp_tool_to_responses_api_tool; +use codex_tools::request_permissions_tool_description; +use codex_tools::request_user_input_tool_description; use codex_tools::tool_spec_to_code_mode_tool_definition; -use codex_utils_absolute_path::AbsolutePathBuf; -use codex_utils_template::Template; -use serde::Deserialize; -use serde::Serialize; -use serde_json::Value as JsonValue; -use serde_json::json; -use std::collections::BTreeMap; use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::LazyLock; - -pub type JsonSchema = codex_tools::JsonSchema; #[cfg(test)] pub(crate) use codex_tools::mcp_call_tool_result_output_schema; -const TOOL_SEARCH_DESCRIPTION_TEMPLATE_SOURCE: &str = - include_str!("../../templates/search_tool/tool_description.md"); -const TOOL_SEARCH_DESCRIPTION_TEMPLATE_KEY: &str = "app_descriptions"; -static TOOL_SEARCH_DESCRIPTION_TEMPLATE: LazyLock